Skip to content

feat(mobile): add composer attachment menu with video support - #235

Merged
rynfar merged 7 commits into
pylonfrom
upstream/2026-09-01-attachment-menu
Sep 1, 2026
Merged

feat(mobile): add composer attachment menu with video support#235
rynfar merged 7 commits into
pylonfrom
upstream/2026-09-01-attachment-menu

Conversation

@rynfar

@rynfar rynfar commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Last of the mobile batch, closing DEF-10. Adopted from upstream pingdotgg/t3code#8843 (e3dcc1615) plus its follow-up f9137a0c8.

Stacked on #233 — #8843 lands after #8614 upstream, so this branches from the voice-input work. Merge #233 first.

What it does

The + control opens a native menu anchored beside it with Photo Library and Choose Files, replacing the Alert sheet. Videos are accepted against the server's file upload limit. An empty composer also shows the interrupt button while the agent works; adding text or an attachment swaps it for send.

The restructure, one last time

Twelve of the thirteen ThreadComposer conflicts were #8793's toolbar restructure arriving as context yet again — declined, as in #223, #231 and #233. The genuine content is ComposerAttachmentButton and the showStopAction change, both placed into Pylon's structure: the button lives inside Pylon's toolbar scroller rather than upstream's fixed left group.

Worth noting the split is holding — the ComposerSurface hunks adopted in #233 did not conflict this time.

Two silent drops a clean typecheck would not have caught

Both arrived through non-conflicting hunks, which is the failure mode this batch keeps producing:

  • ComposerToolbarButton was removed from the import list. Upstream's restructure replaced every call site, so they dropped the import. Pylon still has eleven call sites. This one did surface as a type error only because the identifier was referenced; had the import merely gone unused it would have passed silently.
  • IconPhoto arrived in the symbol map with no import behind it. Upstream adds it via a per-icon deep import (@tabler/icons-react-native/IconPhoto) while Pylon uses the barrel, so the map entry merged cleanly and the import did not. Re-applied in Pylon's style.

I also broke AppSymbol.tsx mid-resolution with an over-eager union script and restored it from HEAD rather than patching over the damage.

Verification

Typecheck clean, lint clean, 1062 tests passing. The follow-up commit f9137a0c8 applies line-for-line identical to upstream.

Not yet run on a simulator. The native menu is the thing to check — it is a @react-native-menu/menu surface, so it needs a device or simulator pass rather than unit coverage.

Pylon features intact: scroller 3, ControlPillMenu 13, QuickQuestionTrigger 2, ContextWindowIndicator 3, ComposerToolbarButton 11.

Reviewed and integrated with Claude Opus 5 in Claude Code.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

NOT READY TO MERGE. Parked as a branch commit so the conflict resolution
is not lost.

Done: all five conflicts resolved, the two new patched native deps
(@react-native-ai/apple, expo-audio) installed, the voice-input feature
directories and client-runtime module landed intact, and ThreadComposer
compiles with the controller wired (composerOwnerKey, useVoiceInputController,
resolveVoiceComposerPresentation, showsCompactDictation).

Also resolved use-composer-command-menu.ts Pylon-first: upstream's file is far
smaller than Pylon's, so every Pylon export (buildComposerCommandItems,
resolveComposerProviderSlashCommands, and the ranking helpers) is kept and only
upstream's composerSelectionAtEnd helper and owner-key ref are added.

Not done, and the reason this is parked: the dictation UI is imported but not
rendered. ComposerDictationToolbar, ComposerDictationPrimaryAction,
ComposerDictationStatus and ComposerDictationCancelAction are all unused, so the
mic never appears. Wiring them means restructuring Pylon's composer rather than
patching it — upstream wraps its toolbar row directly, while Pylon's is
ComposerToolbarRow > ComposerToolbarScroller with thirteen controls. Pylon also
declares canSend roughly 750 lines above where voiceInput can exist, so even
`canSend && !voiceInput.blocksSubmission` needs the declaration order changed.

Shipping it compiling-but-inert would look done and do nothing, so it waits.
…lbar

#8793 changed two independent things in the mobile composer, and treating
them as one decision has cost a conflict in every cherry-pick from
upstream's 2026-08-30 batch since.

The first is ComposerSurface: animate borderRadius on a shared value, put
the glass on an absolute layer, render children in their own animated view,
and bound the collapsed pill radius so the morph interpolates instead of
travelling from 999. Nothing in it touches the toolbar row.

The second is the toolbar row: drop ComposerToolbarScroller for a fixed flex
row. That one genuinely conflicts. ComposerToolbarScroller is upstream's own
component and they still ship it; they stopped using it because their
toolbar holds four controls. Pylon's holds sixteen, because ControlPillMenu
- which does not exist upstream at all - carries Refine, session goal,
context window, agent count, the input queue, depth, resources, and reload.
Those need the scroller.

So take the first, decline the second. ComposerSurface is now structurally
identical to upstream (animatedBorderRadius, AnimatedGlassSurface,
layoutTransition, animatedShapeStyle, and the bounded radius all match), and
the toolbar is untouched: scroller, 13 ControlPillMenus, QuickQuestionTrigger
and ContextWindowIndicator all at their previous counts.

Pylon's shadow wrapper survives with its comment; upstream has no equivalent,
and the radius it carries is now animated alongside the surface.
Completes the #8614 port's UI half. Upstream places the dictation controls
inside its restructured collapsed row and fixed toolbar; Pylon declined that
restructure, so they are placed into Pylon's own structure instead.

The toolbar now shows whenever isToolbarVisible rather than only when
expanded, so dictation stays reachable from the collapsed pill, and it is
wrapped in ComposerDictationToolbar. The cancel action leads the row; while
dictating, ComposerDictationStatus replaces the toolbar scroller rather than
upstream's fixed left group, so Pylon's thirteen ControlPillMenu controls
keep their scroller when not dictating. The mic sits beside send in both the
collapsed row and the toolbar, and send is hidden while dictation owns the
row.

Scroller, ControlPillMenu, QuickQuestionTrigger and ContextWindowIndicator
are all at unchanged counts.
The #8614 port carried upstream's string verbatim: "Allow T3 Code to use
your microphone for voice input." iOS shows that text in the permission
dialog, so it is product copy, not a compatibility identifier. The camera
permission two lines below already reads "Allow Pylon to access your
camera", so this was purely adoption drift.

Verified while checking permissions that no speech-recognition key is
needed: @react-native-ai/apple uses SpeechAnalyzer and SpeechTranscriber,
Apple's on-device Speech framework, rather than SFSpeechRecognizer. Only
NSMicrophoneUsageDescription applies, and it is present.
Adversarial review of the hand-placed dictation UI found three ways the
composer strands the user. All three are in code written by hand rather
than ported, and none was caught by typecheck, lint, or 1042 tests.

The editor was never frozen. NewTaskDraftScreen passes
readOnly={voiceInput.freezesEditor}; the thread composer did not, so the
keyboard stayed live during recording. One keystroke makes
resolveTranscriptCommit see a changed draft and discard the entire
transcript as stale - up to five minutes of speech, silently. It also made
every native read-only guard this branch adds dead code on this surface.

Send was gated on isVoiceInputPresented rather than
voicePresentation.showsSend. Those look equivalent but diverge in exactly
one phase: error shows a status label AND keeps send. Any dictation failure
- denied permission, no speech detected, the stale-draft error above - left
the composer with no send control until the user found the dismiss button.
Worse on entry, since dispose() no-ops in the error phase, so a stale error
survives navigating away and back.

Stop sat inside ComposerToolbarScroller, which is the else branch of the
dictation ternary, so an agent was unstoppable for the whole recording and
transcription window. Moved to the always-rendered right cluster.

Also: guard both submission entry points on blocksSubmission, since canSend
is derived above voiceInput and cannot include it; move the 4px spacer
outside ComposerDictationToolbar's fixed 44px box, where it was overflowing
and clipping the collapsed dictation strip; restore pointerEvents="none" on
the glass layer with a comment matching the new sibling structure; and
rebrand two T3 Code strings in docs.

The showsSend divergence now has a regression test. It is the one defect
here that is a pure predicate rather than JSX placement, and it is the one
most likely to be reintroduced.
Adopted from upstream `pingdotgg/t3code#8843` (`e3dcc1615`), stacked on the
voice-input branch because #8843 lands after #8614 upstream.

The + control now opens a native menu anchored beside it with Photo Library
and Choose Files, replacing the Alert sheet, and videos are accepted against
the server's file upload limit. An empty composer also shows the interrupt
button while the agent works; adding text or an attachment swaps it for send.

Twelve of the thirteen ThreadComposer conflicts were #8793's toolbar
restructure arriving as context again, declined as before. The genuine
content is ComposerAttachmentButton and the showStopAction change, both
applied into Pylon's structure: the button sits inside Pylon's toolbar
scroller rather than upstream's fixed left group.

Two silent drops caught that a clean typecheck would not have:
ComposerToolbarButton was removed from the import list through a
non-conflicting hunk, because upstream's restructure replaced every call
site - Pylon still has eleven. And upstream adds IconPhoto via a per-icon
deep import while Pylon uses the barrel, so the icon arrived in the symbol
map with no import behind it.

docs/user/composer.md keeps Pylon's voice and branding; upstream's text says
T3 Code.
Adopted from upstream `pingdotgg/t3code` `f9137a0c8`, the follow-up to
#8843. Applies line-for-line identical to upstream.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.7 KiB 13.5 KiB −211 B (−1.5%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB +6 B (+0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.8 KiB 6.6 KiB −217 B (−3.1%) 7.8 KiB
Codex Live turn WebSocket decoded 58.7 KiB 57.1 KiB −1.5 KiB (−2.6%) 66.4 KiB
Codex Live turn messages 11 9 −2 (−18.2%) 21
Claude Total thread wire 13.5 KiB 13.6 KiB +41 B (+0.3%) 15.1 KiB
Claude Thread snapshot wire 6.9 KiB 6.9 KiB +2 B (+0.0%) 7.3 KiB
Claude Live turn WebSocket wire 6.6 KiB 6.6 KiB +39 B (+0.6%) 7.8 KiB
Claude Live turn WebSocket decoded 58.0 KiB 58.1 KiB +44 B (+0.1%) 66.4 KiB
Claude Live turn messages 9 10 +1 (+11.1%) 21

Baseline: 81bdef6 · PR result: e771165 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@rynfar
rynfar merged commit afd26d5 into pylon Sep 1, 2026
19 checks passed
@rynfar
rynfar deleted the upstream/2026-09-01-attachment-menu branch September 1, 2026 23:28
rynfar added a commit that referenced this pull request Sep 1, 2026
The composer attachment menu landed 2026-09-01. DEF-10 existed because the
work was approved but unstarted and sat before the cursor, where no future
commit range would have surfaced it. It served its purpose; the register
holds open questions only.

DEF-7 and DEF-8 carry forward unchanged. DEF-8's Expo SDK 57 blocker has
cleared, leaving only its 2026-09-05 floor.
rynfar added a commit that referenced this pull request Sep 1, 2026
docs(agents): retire DEF-10, adopted as #235
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant