Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Steps to reproduce
From the repository root of a checkout of the commit under Version or commit, with its locked dependencies installed, run sh repro.sh with the script below. It creates a private temporary HOME, TMPDIR and T3CODE_HOME, writes one temporary test at apps/mobile/src/state/zz-image-share-title-repro.test.ts, runs only that test with env -u NODE_ENV vp test run, and removes the fixture on exit. Test failures propagate, so it exits 0 only while the defect and the working controls are present.
The test passes an image-only native-share payload containing a small valid PNG to exported buildIncomingShareDraft, imports it through makeIncomingShareImports with the production attachment selection and mergeComposerDraftContentState, and builds the queued creation from the resulting draft text and attachments as new-task-flow-provider.tsx does. It then passes that creation to the production buildPendingNewTasks, pendingThreadCreationShell, withLaunchingThreads and buildProjectThreadStartTurnInput across offline, synchronizing, retry-held and online send gates, and supplies an authoritative shell carrying the outgoing title to check that it replaces the launching label. A non-image file-only share and a text-only share are controls. Its native-file reader is a boundary adapter; it does not start a provider or server, and it does not mount the React new-task provider.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Run from the root of an installed checkout: sh repro.sh
# The test asserts the observed defect and the file/text controls; test failures propagate.
checkout=$(pwd)
: "${REPRO_SCRATCH_PARENT:=${TMPDIR:-/tmp}}"
tmp=$(mktemp -d "$REPRO_SCRATCH_PARENT/attachment-title.XXXXXX")
fixture="$checkout/apps/mobile/src/state/zz-image-share-title-repro.test.ts"
trap 'rm -f "$fixture"; rm -rf "$tmp"' EXIT HUP INT TERM
mkdir -p "$tmp/profile" "$tmp/tmp" "$tmp/t3"
export HOME="$tmp/profile" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3"
export PATH="$checkout/node_modules/.bin:$PATH"
cat > "$fixture" <<'TEST'
import { describe, it, expect } from "vite-plus/test";
import { AtomRegistry } from "effect/reactivity";
import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { buildIncomingShareDraft, selectIncomingShareAttachmentsForServer } from "../features/sharing/incoming-share-model";
import { makeIncomingShareImports } from "../features/threads/incomingShareImport";
import { mergeComposerDraftContentState, type ComposerDraft } from "./use-composer-drafts";
import { buildPendingNewTasks, isQueuedCreationSending, type ThreadOutboxSendGate } from "./pending-new-tasks-model";
import { isQueuedThreadCreationSendable, type QueuedThreadMessage } from "./thread-outbox-model";
import { pendingThreadCreationShell, withLaunchingThreads } from "./pending-thread-creation";
import { buildProjectThreadStartTurnInput, deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";
const env = EnvironmentId.make("environment-1");
const projectId = ProjectId.make("project-1");
const createdAt = new Date(0).toISOString();
const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test-model" };
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const draftKey = "new-task:environment-1:project-1";
describe("production mobile incoming-share title flow", () => {
it("compares incoming image, shared file, and text control across held and sending states", async () => {
for (const kind of ["image", "file", "text"] as const) {
const share = await buildIncomingShareDraft({
id: `share-${kind}`, createdAt,
payloads: kind === "text" ? [{ shareType: "text", value: "Fix the login bug" }] : [{ shareType: kind, value: `file:///shared/${kind === "image" ? "photo.png" : "report.txt"}`, mimeType: kind === "image" ? "image/png" : "text/plain", originalName: kind === "image" ? "photo.png" : "report.txt" }],
resolvedPayloads: [],
fileReader: { readBase64: async () => png, readSize: async () => 4, removeOwnedFile: async () => {} },
});
expect(share.warnings).toEqual([]);
const registry = AtomRegistry.make();
let drafts: Record<string, ComposerDraft> = {};
const target = { flowKey: `flow-${kind}`, routeKey: `route-${kind}`, draftKey, shareId: share.id, destination: { environmentId: String(env), projectId: String(projectId) } };
const imports = makeIncomingShareImports({
registry, getShare: () => share, reserve: async () => {}, release: async () => {}, consume: async () => {},
readDraft: key => drafts[key] ?? { text: "", attachments: [] },
undoMerge: async () => { throw new Error("unexpected undo"); },
mergeIntoDraft: async (key, content) => { drafts = mergeComposerDraftContentState(drafts, key, content); return { skippedAttachmentCount: 0 }; },
selectAttachments: incoming => selectIncomingShareAttachmentsForServer({ attachments: incoming.attachments, serverConfig: kind === "image" ? null : { environment: { capabilities: { fileAttachments: { maxUploadBytes: 1000000 } } } } }),
releaseUnusedAttachments: () => {}, onWarnings: warnings => { throw new Error(warnings.join(";")); },
onImportFailure: error => { throw new Error(String(error)); }, onCancelFailure: () => { throw new Error("unexpected cancellation"); }, leave: () => {},
});
const phase = imports.phaseAtom(target);
const unmount = registry.mount(phase);
registry.get(phase);
await imports.settled(target);
expect(registry.get(phase)).toBe("done");
const draft = drafts[draftKey]!;
// new-task-flow-provider.tsx:690-748 copies trimmed draft text and attachments.
const message: QueuedThreadMessage = {
environmentId: env, threadId: ThreadId.make(`thread-${kind}`), messageId: MessageId.make(`message-${kind}`), commandId: CommandId.make(`command-${kind}`),
text: draft.text.trim(), attachments: draft.attachments, context: draft.context, modelSelection,
creation: { projectId, projectCwd: "/workspace/project", workspaceMode: "local", branch: null, worktreePath: null }, createdAt,
};
expect(isQueuedThreadCreationSendable(message)).toBe(true);
const uploaded = message.attachments.map(attachment => ({ id: attachment.id, type: attachment.type, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes }));
// threadOutboxDelivery.ts:1097-1127 supplies raw titleText plus serialized message.
const start = buildProjectThreadStartTurnInput({
projectId, projectCwd: "/workspace/project", threadId: message.threadId, commandId: message.commandId, messageId: message.messageId, createdAt,
titleText: message.text,
...serializeComposerMessageForServer(message.text, uploadedComposerContext(message.context, message.attachments, uploaded)),
uploadedAttachments: uploaded, modelSelection, runtimeMode: "full-access", interactionMode: "default",
workspaceMode: "local", branch: null, worktreePath: null, startFromOrigin: false, worktreeBranchName: "unused",
});
const online: ThreadOutboxSendGate = { connectedEnvironmentIds: new Set([env]), operableEnvironmentIds: new Set([env]), shellStatuses: new Map([[env,"live"]]), retryingMessageIds: {} };
const gates = {
offline: { ...online, connectedEnvironmentIds: new Set<EnvironmentId>(), operableEnvironmentIds: new Set<EnvironmentId>() },
synchronizing: { ...online, shellStatuses: new Map([[env, "synchronizing" as const]]) },
retryHeld: { ...online, retryingMessageIds: { [message.messageId]: true as const } },
online,
};
const expected = kind === "image" ? "Attached image" : kind === "file" ? "report.txt" : "Fix the login bug";
expect(start.titleSeed).toBe(expected);
expect(start.bootstrap.createThread.title).toBe(expected);
const shell = pendingThreadCreationShell(message)!;
const rows: Record<string, string[]> = {};
for (const [name, gate] of Object.entries(gates)) {
const pending = buildPendingNewTasks({ queuedMessages: [message], drafts: {}, sendGate: gate });
rows[name] = pending.map(row => row.title);
expect(rows[name]).toEqual(name === "online" ? [] : [kind === "image" ? "New thread" : expected]);
if (isQueuedCreationSending(message, gate)) {
const launching = withLaunchingThreads({ threads: [], launches: [message], hiddenThreadKeys: new Set() });
expect(launching[0]?.title).toBe(kind === "image" ? "New thread" : expected);
const arrived = withLaunchingThreads({ threads: [{ ...shell, title: start.bootstrap.createThread.title }], launches: [message], hiddenThreadKeys: new Set() });
expect(arrived[0]?.title).toBe(expected);
}
}
if (kind === "image") { expect(message.text).toBe(""); expect(shell.title).toBe("New thread"); }
else { expect(message.text.length).toBeGreaterThan(0); expect(shell.title).toBe(expected); }
process.stdout.write(`PROBE ${kind}: ${JSON.stringify({ shareText: share.text, queuedText: message.text, attachmentCount: message.attachments.length, sendable: true, pendingRows: rows, pendingShell: shell.title, sentBootstrapTitle: start.bootstrap.createThread.title, sentTitleSeed: start.titleSeed, titleAfterShellArrives: expected })}\n`);
unmount(); registry.dispose();
}
});
});
TEST
env -u NODE_ENV vp test run apps/mobile/src/state/zz-image-share-title-repro.test.tsIn the app, the production path is the mobile native share inbox, then the new-task draft import, then Send or Queue. The importer copies shared text and attachments into the draft; the new-task builder permits blank text with attachments and copies both into the queued creation; the Send handler enqueues before navigating and clearing the draft. That in-app path was traced in source, not run on a device.
Expected behavior
For a new task consisting only of shared images, the pending and launching title uses the established readable image fallback, Attached image or Attached images, matching the title the same task is created with. The shared title helper defines that fallback, and mobile's outgoing creation builder uses it for both titleSeed and bootstrap.createThread.title. deriveThreadTitleSeed was introduced in merged #2829 (de34391427) with an attachment-name fallback (Image: <name>); merged #19822 (b20f55248d) replaced it with the current Attached image(s) count fallback and describes readable titles across "mobile drafts, queued submissions, and both web send paths", including image-only prompts. #19822 does not name the two pending-title builders below; applying its readable-title intent to them is an inference from that stated scope and from the creation title, not an explicit exact-title contract.
Actual behavior
When a new task on mobile is created from an incoming native share that contains only an image (no text or URL), the task's temporary title in the pending rows (Home list and navigation sidebar), the launching row, and the opened thread header is New thread, while the outgoing creation request for the same task carries Attached image. The pending title stays generic until an authoritative server shell or detail arrives; for offline, synchronizing or retry-held creations there is no deadline. The image stays attached and the task remains sendable. The confirmed scope is image-only incoming shares: file-only shares and images attached with the picker receive inline references in the draft text and are not affected.
The script passed on a checkout of the commit under Version or commit and left git status clean. The image-only share yields empty draft and queued text plus one image attachment, and the creation is sendable. Offline, synchronizing and retry-held pending rows read New thread, and the pending shell reads New thread. While actively sending online, the ordinary Unsent row is intentionally omitted and the launching row takes its place with the same New thread title. The outgoing creation request carries Attached image in both title fields, and a supplied authoritative shell replaces the launching title with Attached image. The text-only and file-only controls produce matching titles everywhere:
PROBE image: {"shareText":"","queuedText":"","attachmentCount":1,"sendable":true,"pendingRows":{"offline":["New thread"],"synchronizing":["New thread"],"retryHeld":["New thread"],"online":[]},"pendingShell":"New thread","sentBootstrapTitle":"Attached image","sentTitleSeed":"Attached image","titleAfterShellArrives":"Attached image"}
PROBE file: {"shareText":"","queuedText":"[report.txt](t3-context://v1/file/share-file_file_0)","attachmentCount":1,"sendable":true,"pendingRows":{"offline":["report.txt"],"synchronizing":["report.txt"],"retryHeld":["report.txt"],"online":[]},"pendingShell":"report.txt","sentBootstrapTitle":"report.txt","sentTitleSeed":"report.txt","titleAfterShellArrives":"report.txt"}
PROBE text: {"shareText":"Fix the login bug","queuedText":"Fix the login bug","attachmentCount":0,"sendable":true,"pendingRows":{"offline":["Fix the login bug"],"synchronizing":["Fix the login bug"],"retryHeld":["Fix the login bug"],"online":[]},"pendingShell":"Fix the login bug","sentBootstrapTitle":"Fix the login bug","sentTitleSeed":"Fix the login bug","titleAfterShellArrives":"Fix the login bug"}
Test Files 1 passed (1)
Tests 1 passed (1)
The pending list derives the title from message.text alone, and the pending shell does the same. An image imported through the share importer gets no inline text reference, because the merge's file-reference restoration skips images. Ordinary shared files receive inline references during the merge, and picker images receive references through appendReference, so those inputs give the text-only title path enough information and are outside this report's confirmed scope. Shared videos and other image-MIME file paths were not tested end to end.
In source, the value reaches the Home list and sidebar pending row and the launching row, and the opened thread header: thread selection falls back to the pending shell, and the header renders selectedThread.title. The pending-task list filters by environment, project and search, without hiding offline items. The generic label lasts until authoritative shell or detail state arrives; offline or held, it has no fixed deadline. Native rendering and the exact online duration were not measured, and this report makes no claim about the title the server persists beyond the outgoing request.
Evidence
- Expected source: the shared
Attached image(s)fallback inpackages/client-runtime/src/operations/threadTitle.tslines 43-52, its use for the outgoing creation title inapps/mobile/src/lib/projectThreadStartTurn.tslines 40-68, and the readable-title scope stated in merged fix(clients): image threads use readable titles #19822. - Failure source: the reproduction output above from the exported production functions;
buildPendingNewTasks(apps/mobile/src/state/pending-new-tasks-model.tsline 139) andpendingThreadCreationShell(apps/mobile/src/state/pending-thread-creation.tsline 176) callderiveThreadTitleFromPrompt(message.text)with text only. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The test assembles the queued message using the new-task builder's exact text and attachment copy rather than invoking the React provider closure; that producer step and the UI consumers were traced in source. The existing pending-new-tasks-model.test.ts and pending-thread-creation.test.ts cover offline, synchronizing and retry-held creations and server-shell precedence, but neither pins New thread as the intended title of an image-only creation.
Related
Merged #33783 (already in the tested commit) changed these pending builders for the draft-to-Working transition and leaves this title path text-only. Open #27389 concerns explicit draft route headings and keeps New thread for ordinary draft headers; open #28483 removes picker image chips from composer text and changes no title helper, so it could widen the affected inputs rather than fix this; open #32224 normalizes existing-thread queue-sheet message text. Closed, unmerged #30347 concerns surrogate truncation in the text-only helper. Closed #14049 concerns the image-only prompt text sent to the agent, not titles. None changes these new-task titles.
Restoration check
With the script's image-only share, the offline, synchronizing and retry-held pending rows, the pending shell and the launching row read the same readable image title as the outgoing creation request (Attached image) instead of New thread. The script's New thread assertions encode the current defect and fail once it is fixed. Controls keep passing: the text-only share still reads Fix the login bug and the file-only share still reads report.txt everywhere, online pending rows stay empty while the launching row is shown, and an arriving authoritative shell still supplies the final title.
Impact
Cosmetic issue
Version or commit
T3 Code at d9805b2, reproduced from a checkout of that commit with the script above. The same script also reproduced the result at upstream main a7779fc. The eight later commits on current main (77f40af) change none of the producer, merge, title or consumer files involved; the script was not rerun there.
Environment
Darwin arm64, Node v26.12.1, repository test runner Vitest v5.0.5. This is source-and-run verification of exported production mobile functions; no device, simulator, native app, provider turn or server was run. Native rendering and the exact online duration of the generic label were not measured.
Logs or stack traces
Output of the script on the commit above:
PROBE image: {"shareText":"","queuedText":"","attachmentCount":1,"sendable":true,"pendingRows":{"offline":["New thread"],"synchronizing":["New thread"],"retryHeld":["New thread"],"online":[]},"pendingShell":"New thread","sentBootstrapTitle":"Attached image","sentTitleSeed":"Attached image","titleAfterShellArrives":"Attached image"}
PROBE file: {"shareText":"","queuedText":"[report.txt](t3-context://v1/file/share-file_file_0)","attachmentCount":1,"sendable":true,"pendingRows":{"offline":["report.txt"],"synchronizing":["report.txt"],"retryHeld":["report.txt"],"online":[]},"pendingShell":"report.txt","sentBootstrapTitle":"report.txt","sentTitleSeed":"report.txt","titleAfterShellArrives":"report.txt"}
PROBE text: {"shareText":"Fix the login bug","queuedText":"Fix the login bug","attachmentCount":0,"sendable":true,"pendingRows":{"offline":["Fix the login bug"],"synchronizing":["Fix the login bug"],"retryHeld":["Fix the login bug"],"online":[]},"pendingShell":"Fix the login bug","sentBootstrapTitle":"Fix the login bug","sentTitleSeed":"Fix the login bug","titleAfterShellArrives":"Fix the login bug"}
Test Files 1 passed (1)
Tests 1 passed (1)
Screenshots, recordings, or supporting files
No response
Workaround
Include descriptive text with the shared image. Both title computations then use that text, as the text-only control shows through the production functions; this was not exercised on a device.
Activity
juliusmarminge commentedon Nov 12, 2026
I tried to reproduce this on the current build and could not.
On origin/main 78ee8a1 I added one unit test to each existing test file, apps/mobile/src/state/pending-new-tasks-model.test.ts and apps/mobile/src/state/pending-thread-creation.test.ts, and ran them with
env -u NODE_ENV vp test runon those two files. The first test passes a queued creation with empty text and one image attachment through buildPendingNewTasks while offline. The second passes the existing image-bearing creation fixture with empty text through withLaunchingThreads. Both fail: expected 'New thread' to be 'Attached image'. The other 12 tests in the two files pass. I did not run this on a device: getting a native image share into the dev client on a pooled emulator, with the send held, was too much for this budget, so the app was not seen doing it in a real run. The source shows the outgoing create request titles the same creation 'Attached image' through deriveThreadTitleSeed.Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.