Skip to content

Commit 8221250

Browse files
committed
fix(mobile): send file attachments on queued follow-ups
The previous commit filtered files out of onQueueFollowUp and called it a queue limitation. That was wrong, and worse than it looked. Pylon's follow-up command already takes the same attachment union as thread.turn.start (orchestration.ts), its own comment says the server normalizer branches on `"dataUrl" in attachment` for both, and web passes identical attachments to followUpInputQueue and startTurn. Files were always supported; the filter was a fix for a type error dressed up as a design decision. The consequence was silent data loss on an ordinary path, not an edge case: the composer's send button routes to handleQueueFollowUp whenever a session is running, so attaching a file mid-turn and pressing send filtered it out of the payload, and because clearComposerDraftContent ran without deferAttachmentCleanup the sweep then deleted the local copy. Composer cleared, message queued without the file, no warning, bytes gone. Now mirrors onSendMessage and use-project-actions: enforce the attachment cap, validate against the server's advertised limits, upload through prepareTurnAttachments, defer cleanup until the queue call succeeds, release minted uploads and restore with allowOverflow on failure. Also switches the four file glyphs to `tintColorClassName` rather than upstream's hardcoded #a3a3a3. An earlier attempt subscribed to the theme directly and tripped no-mobile-uniwind-theme-escape-hatches; AppSymbol exists precisely so callers do not do that.
1 parent 6fbe4b7 commit 8221250

4 files changed

Lines changed: 76 additions & 17 deletions

File tree

apps/mobile/src/components/ComposerAttachmentStrip.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
7575
borderRadius: radius,
7676
}}
7777
>
78-
<SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" />
78+
<SymbolView
79+
name="doc.text"
80+
size={22}
81+
tintColorClassName="accent-icon-subtle"
82+
type="monochrome"
83+
/>
7984
<Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
8085
{attachment.name}
8186
</Text>

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,6 @@ const ContextWindowIndicator = memo(function ContextWindowIndicator(props: {
418418
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
419419
const navigation = useNavigation();
420420
const foregroundColor = useUniwindTheme()["--color-foreground"];
421-
// Upstream hardcodes #a3a3a3 here; Pylon reads the theme so the file glyph
422-
// tracks light/dark like every other subtle icon.
423-
const iconSubtleColor = useUniwindTheme()["--color-icon-subtle"];
424421
const bodyText = useScaledTextRole("body");
425422
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
426423
const inputRef = props.editorRef ?? fallbackInputRef;
@@ -1595,7 +1592,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
15951592
<SymbolView
15961593
name="doc.text"
15971594
size={15}
1598-
tintColor={iconSubtleColor}
1595+
tintColorClassName="accent-icon-subtle"
15991596
type="monochrome"
16001597
/>
16011598
</View>

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,12 @@ function MessageAttachmentFile(props: {
322322
{opening ? (
323323
<ActivityIndicator size="small" />
324324
) : (
325-
<SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
325+
<SymbolView
326+
name="doc.text"
327+
size={16}
328+
tintColorClassName="accent-icon-subtle"
329+
type="monochrome"
330+
/>
326331
)}
327332
<Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
328333
{attachment.name}
@@ -341,7 +346,12 @@ function MessageAttachmentFile(props: {
341346
function MessageAttachmentUnknown(props: { readonly name: string }) {
342347
return (
343348
<View className="flex-row items-center gap-2 py-1">
344-
<SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
349+
<SymbolView
350+
name="doc.text"
351+
size={16}
352+
tintColorClassName="accent-icon-subtle"
353+
type="monochrome"
354+
/>
345355
<Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
346356
{props.name}
347357
</Text>

apps/mobile/src/state/use-thread-composer-state.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
5858
import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
5959
import {
6060
convertPastedImagesToAttachments,
61-
toUploadChatImageAttachments,
6261
pasteComposerClipboard,
6362
pickComposerFiles,
6463
pickComposerImages,
6564
} from "../lib/composerImages";
6665
import type { DraftComposerImageAttachment } from "../lib/composerImages";
66+
import { prepareTurnAttachments, validateDraftFileAttachments } from "../lib/attachmentUpload";
6767
import { scopedThreadKey } from "../lib/scopedEntities";
6868
import {
6969
resolveModelSelectionRuntimeMode,
@@ -72,6 +72,7 @@ import {
7272
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
7373
import { buildThreadFeed } from "../lib/threadActivity";
7474
import { appAtomRegistry } from "../state/atom-registry";
75+
import { serverEnvironment } from "../state/server";
7576
import {
7677
appendComposerDraftAttachments,
7778
appendComposerDraftText,
@@ -680,9 +681,55 @@ export function useThreadComposerState() {
680681
const attachments = draft.attachments;
681682
if (text.length === 0 && attachments.length === 0) return null;
682683

684+
if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
685+
Alert.alert(
686+
"Too many attachments",
687+
`Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
688+
);
689+
return null;
690+
}
691+
const attachmentError = validateDraftFileAttachments({
692+
attachments,
693+
serverConfig: appAtomRegistry.get(
694+
serverEnvironment.configValueAtom(selectedThreadShell.environmentId),
695+
),
696+
});
697+
if (attachmentError !== null) {
698+
setPendingConnectionError(attachmentError);
699+
return null;
700+
}
701+
683702
const metadata = makeQueuedMessageMetadata();
684703
const messageId = MessageId.make(metadata.messageId);
685-
clearComposerDraftContent(threadKey);
704+
// The follow-up command takes the same attachment union as thread.turn.start
705+
// and the server normalizer branches on `"dataUrl" in attachment` for both,
706+
// so files belong here exactly as they do on a normal send. Upload them
707+
// first; sending the draft form would queue broken references.
708+
let prepared: Awaited<ReturnType<typeof prepareTurnAttachments>>;
709+
try {
710+
prepared = await prepareTurnAttachments({
711+
environmentId: selectedThreadShell.environmentId,
712+
attachments,
713+
persistUploadedReferences: async (draftAttachments) => {
714+
await mergeComposerDraftContent(threadKey, { text, attachments: draftAttachments });
715+
return "persisted";
716+
},
717+
});
718+
} catch (error) {
719+
setPendingConnectionError(
720+
error instanceof Error ? error.message : "An attachment could not upload.",
721+
);
722+
return null;
723+
}
724+
if (prepared.status !== "ready") {
725+
setPendingConnectionError("The attachments are no longer available.");
726+
return null;
727+
}
728+
729+
// Defer cleanup: the sweep would otherwise delete the local bytes while the
730+
// queue call is still in flight, leaving a failure restore pointing at
731+
// files that no longer exist.
732+
clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
686733
const result = await followUpInputQueue({
687734
environmentId: selectedThreadShell.environmentId,
688735
input: {
@@ -692,20 +739,18 @@ export function useThreadComposerState() {
692739
messageId,
693740
role: "user",
694741
text,
695-
// Pylon's follow-up queue has no upload step, so it can only carry
696-
// inline images. This mirrors what projectThreadStartTurn does when
697-
// no upload has run. Files are filtered rather than sent as broken
698-
// references; queuing a follow-up with a file is a known gap.
699-
attachments: toUploadChatImageAttachments(
700-
attachments.filter((attachment) => attachment.type === "image"),
701-
),
742+
attachments: prepared.attachments,
702743
},
703744
createdAt: metadata.createdAt,
704745
},
705746
});
706747
if (result._tag === "Failure") {
748+
prepared.releaseUploads();
707749
await mergeComposerDraftContent(threadKey, { text, attachments: [] });
708-
appendComposerDraftAttachments(threadKey, attachments);
750+
// Uncapped append, matching onSendMessage: the capped merge path would
751+
// silently drop this message's files if the user attached more while the
752+
// call was in flight.
753+
appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
709754
if (!isAtomCommandInterrupted(result)) {
710755
const error = squashAtomCommandFailure(result);
711756
setPendingConnectionError(
@@ -714,6 +759,8 @@ export function useThreadComposerState() {
714759
}
715760
return null;
716761
}
762+
// Queued successfully, so the message owns the bytes now.
763+
scheduleUnusedComposerAttachmentCleanup(attachments);
717764
return messageId;
718765
}, [
719766
followUpInputQueue,

0 commit comments

Comments
 (0)