Skip to content

feat(mobile): make archives one-swipe and recoverable - #5205

Open
Feighery89 wants to merge 21 commits into
pingdotgg:mainfrom
Feighery89:feat/mobile-quick-archive
Open

feat(mobile): make archives one-swipe and recoverable#5205
Feighery89 wants to merge 21 commits into
pingdotgg:mainfrom
Feighery89:feat/mobile-quick-archive

Conversation

@Feighery89

@Feighery89 Feighery89 commented Aug 1, 2026

Copy link
Copy Markdown

What Changed

Mobile archiving required a partial swipe plus an action tap, while recovery lived under Settings. This PR makes the archive lifecycle reversible from the thread list:

  • Full-swiping an active or settled row archives it; snoozed rows keep full-swipe Wake.
  • Long-press menus retain lifecycle actions and expose Archive/Delete.
  • An Archived (N) shelf sits at the bottom of the list, hidden when empty and collapsed by default.
  • Archived rows full-swipe to Restore; partial swipe and long-press expose Restore/Delete.
  • The shelf follows the current environment, project, and search filters across home, compact, and sidebar layouts. It shows 10 rows before linking to the existing full archive screen.

The collapsed, empty-hidden shelf provides point-of-use optionality without adding a persistent setting or permanent list clutter.

Closes #5204.

Why

Archiving should be one deliberate gesture, and mistakes should be recoverable without leaving the thread list. Keeping the complete lifecycle together makes Archive safe enough to be fast.

UI Changes

Before: archives only outside the thread list After: inline collapsed archive shelf
Mobile thread list before the inline archive shelf Expanded Archived shelf at the bottom of the mobile thread list
Archived row partial swipe Archived row long-press
Restore and Delete actions revealed by swiping an archived row Restore and Delete actions in the archived row long-press menu

Watch the 33-second Android archive/restore interaction.

Verification

  • vp test run scripts/mobile-showcase.test.ts apps/mobile/src/features/threads/threadListV2.test.ts apps/mobile/src/features/archive/archivedThreadList.test.ts apps/mobile/src/features/home/homeThreadList.test.ts — 76 tests passed
  • vp run --filter @t3tools/mobile typecheck
  • vp fmt --check
  • Targeted vp lint --report-unused-disable-directives on every changed TypeScript file
  • Android API 36 emulator: UIAutomator assertions verified full-swipe archive, shelf count/update, partial-swipe Restore/Delete, long-press Restore/Delete, full-swipe restore, and return to the active list

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for the UI changes
  • I included a video for the interaction changes

Built with Codex (GPT-5.6) in T3 Code.


Note

High Risk
Large mobile surface area: foreground services, battery/notification permissions, relay auth across UI and background, and CI signing secrets for preview APKs—any regression affects connectivity, notifications, or update delivery.

Overview
Beyond the archive UX work, this PR adds a large Android background connection stack and personal preview delivery path that are unrelated to quick archive alone.

Archive lifecycle on the thread list — Active rows can full-swipe to archive; archived rows support full-swipe Restore, partial swipe, and long-press Restore/Delete. A collapsible Archived (N) shelf at the bottom of home/sidebar lists filtered archived threads (up to 10 inline) with project scoping in archive list building.

Android keep-connected — New Expo module t3-background-connection runs a remoteMessaging foreground service plus Headless JS task that holds connection supervisors, outbox drain, relay auth, and thread detail leases without a second sync protocol. Settings expose Keep connected in background and Agent notifications; foreground return can skip aggressive reconnect when the native runtime reports healthy protection.

Agent notifications — A reducer watches thread shells for completion, failure, approval, and input edges; a service posts high-priority expo-notifications when the app is backgrounded and the preference is on.

Cloud relay ownership — Managed relay sessions split UI vs background owners so Clerk UI unmount does not tear down relay used by the headless task; background auth bootstraps Clerk with retry and invalidation on sign-out.

Personal Android preview — GitHub Actions rebases a pinned personal feature branch onto upstream, signs com.t3tools.t3code.preview, disables official Expo OTA, and publishes immutable GitHub releases; preview builds read update API and default environment host from env/extra.

Reviewed by Cursor Bugbot for commit 4c95746. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add Android background connection service and make thread archiving the primary one-swipe action

  • Introduces an Android foreground service (T3BackgroundConnection) that runs a headless JS task to maintain relay auth, mount thread atoms, drain the outbox, and send agent notifications while the app is backgrounded; the service survives device reboot and app replacement via a BroadcastReceiver.
  • Adds a settings UI (BackgroundConnectionSettingsSection) for enabling/disabling the background connection, prompting for battery optimization exemption, and managing Android notification permissions.
  • Makes Archive the universal primary swipe action for all v2 thread list rows, replacing the previous settle/unsettle behavior; archived threads now appear in an inline shelf on the Home screen and in the sidebar, with Restore and Delete menu actions.
  • Adds a managedRelaySessionOwnership layer that coordinates UI and background relay session ownership, preventing stale background auth from overwriting an active UI session.
  • Introduces a personal preview Android APK self-update flow gated on a GitHub Releases endpoint, with a CI workflow and systemd timer to build and dispatch it daily.
  • Risk: the background service acquires a high-performance Wi-Fi lock and posts a persistent foreground notification on Android; enabling it opts the user into always-on battery consumption.

Macroscope summarized 4c95746.

Built with Codex (GPT-5.6) in T3 Code.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 05266f19-2065-4da6-ba9b-cf0cbbaa8976

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 1, 2026
Comment thread apps/mobile/src/features/archive/ArchivedThreadsShelf.tsx
Comment thread apps/mobile/src/features/home/HomeScreen.tsx
Comment thread apps/mobile/src/features/home/HomeScreen.tsx
@macroscopeapp

macroscopeapp Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

5 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Aug 1, 2026
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Aug 1, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One finding on Effect service conventions in apps/mobile/src/persistence/mobile-storage.ts. Everything else in the changed Effect scope (service interface additions, make/layer shape, namespace imports, imperative boundary adapter) looks consistent with the conventions.

Posted via Macroscope — Effect Service Conventions

Comment on lines +284 to +293
Effect.catch((cause) =>
Effect.logWarning("Ignored an invalid retained background thread.").pipe(
Effect.annotateLogs({
storageKey: BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
cause: String(cause),
}),
Effect.andThen(secureStorage.removeItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY)),
Effect.as(null),
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recovery path serializes the decode failure into a parallel log payload (cause: String(cause)), which copies arbitrary defect text (and the malformed stored value it usually embeds) into logs instead of preserving the exact underlying value as a structured cause. The existing MobileStorageDecodeError already carries key + cause and is used for the same situation in parseJson above; logging it keeps the context bounded and the error chain intact.

Suggested change
Effect.catch((cause) =>
Effect.logWarning("Ignored an invalid retained background thread.").pipe(
Effect.annotateLogs({
storageKey: BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
cause: String(cause),
}),
Effect.andThen(secureStorage.removeItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY)),
Effect.as(null),
),
),
Effect.catch((cause) =>
Effect.logWarning(
new MobileStorageDecodeError({
key: BACKGROUND_CONNECTION_RETAINED_THREAD_KEY,
cause,
}),
).pipe(
Effect.andThen(secureStorage.removeItem(BACKGROUND_CONNECTION_RETAINED_THREAD_KEY)),
Effect.as(null),
),
),

Posted via Macroscope — Effect Service Conventions

val wasEnabled = isEnabled(context)
// Commit before starting the service so a process death cannot start a
// sticky service whose boot-time preference still says it is disabled.
preferences(context).edit().putBoolean(ENABLED_KEY, enabled).commit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium t3backgroundconnection/T3BackgroundConnectionState.kt:63

setEnabled ignores the boolean returned by SharedPreferences.Editor.commit(), so a failed synchronous write leaves the persisted preference unchanged while the method proceeds as though the toggle succeeded. When enabling, ensureStarted rereads the old disabled value and never starts the service; when disabling, the service keeps running because requestOrderlyStop rereads the old enabled value. The async API still resolves successfully, so the user's requested setting silently fails. Consider checking the return value of commit() and surfacing the failure to the caller instead of continuing.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-background-connection/android/src/main/java/expo/modules/t3backgroundconnection/T3BackgroundConnectionState.kt around line 63:

`setEnabled` ignores the boolean returned by `SharedPreferences.Editor.commit()`, so a failed synchronous write leaves the persisted preference unchanged while the method proceeds as though the toggle succeeded. When enabling, `ensureStarted` rereads the old disabled value and never starts the service; when disabling, the service keeps running because `requestOrderlyStop` rereads the old enabled value. The async API still resolves successfully, so the user's requested setting silently fails. Consider checking the return value of `commit()` and surfacing the failure to the caller instead of continuing.

let preferencesRelease: (() => void) | null = null;
let threadsRelease: (() => void) | null = null;

const reduce = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agent-notifications/thread-notification-service.ts:43

When dependencies.present rejects for an event, that event remains marked as emitted in state.emittedEventIds and is never retried, yet its ID is still persisted via persistEventIds. The notification is permanently lost. The reduce function assigns state = reduction.state (which already records every event ID as emitted) before the async present calls run, so the .catch handler only logs the failure without removing the IDs from state. Consider updating state only after the events are successfully presented, or removing failed event IDs from state.emittedEventIds in the .catch handler so they can be retried on the next reduction.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/agent-notifications/thread-notification-service.ts around line 43:

When `dependencies.present` rejects for an event, that event remains marked as emitted in `state.emittedEventIds` and is never retried, yet its ID is still persisted via `persistEventIds`. The notification is permanently lost. The `reduce` function assigns `state = reduction.state` (which already records every event ID as emitted) before the async `present` calls run, so the `.catch` handler only logs the failure without removing the IDs from state. Consider updating `state` only after the events are successfully presented, or removing failed event IDs from `state.emittedEventIds` in the `.catch` handler so they can be retried on the next reduction.

append(retainedThread);
}
for (const thread of threadShells) {
if (thread.session?.status === "starting" || thread.session?.status === "running") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium background-connection/target-selection.ts:31

selectBackgroundConnectionThreadTargets only checks thread.session?.status for "starting" or "running", but a thread is also considered active when thread.latestTurn?.state === "running" with a null or "ready" session. A shell in that state is omitted from the target list, so background-root.ts releases (or never acquires) its detail lease while the thread still has an active turn. That thread stops receiving background state updates until the session status transitions to "starting"/"running" or the turn finishes. The "running" latest-turn condition should be included alongside the session status check.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/background-connection/target-selection.ts around line 31:

`selectBackgroundConnectionThreadTargets` only checks `thread.session?.status` for `"starting"` or `"running"`, but a thread is also considered active when `thread.latestTurn?.state === "running"` with a `null` or `"ready"` session. A shell in that state is omitted from the target list, so `background-root.ts` releases (or never acquires) its detail lease while the thread still has an active turn. That thread stops receiving background state updates until the session status transitions to `"starting"`/`"running"` or the turn finishes. The `"running"` latest-turn condition should be included alongside the session status check.

})
.catch((error) => {
console.error("[agent-notifications] failed to deliver Android notification", error);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification persist skips on present failure

Medium Severity

The notification service marks an event as emitted in its in-memory state before successfully presenting it or persisting its ID. If presentation or persistence fails, the user won't see the notification, and it won't be re-attempted.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f05becb. Configure here.

Comment thread apps/mobile/src/connection/app-state-wakeups.ts

const connectDisabled = isSubmitting || hostInput.trim().length === 0;

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High connection/ConnectionsNewRouteScreen.tsx:48

When the route is opened with params.pairingUrl, the sync effect overrides any later user edits or QR scan results. Scanning a new QR code calls onChangeConnectionPairingUrl, which reruns the effect and restores hostInput/codeInput to the original route URL. Manually editing the host or code and submitting likewise triggers onChangeConnectionPairingUrl, rerunning the effect and resetting the form to the route-provided values. Users cannot replace or correct a route-provided pairing target. Consider syncing params.pairingUrl only on initial mount, or guarding the effect so it does not restore route values after the user has taken an action.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx around line 48:

When the route is opened with `params.pairingUrl`, the sync effect overrides any later user edits or QR scan results. Scanning a new QR code calls `onChangeConnectionPairingUrl`, which reruns the effect and restores `hostInput`/`codeInput` to the original route URL. Manually editing the host or code and submitting likewise triggers `onChangeConnectionPairingUrl`, rerunning the effect and resetting the form to the route-provided values. Users cannot replace or correct a route-provided pairing target. Consider syncing `params.pairingUrl` only on initial mount, or guarding the effect so it does not restore route values after the user has taken an action.

Comment thread apps/mobile/src/features/updates/app-updates.ts
}, [connectionPairingUrl]);
if (routePairingUrl && routePairingUrl !== connectionPairingUrl) {
onChangeConnectionPairingUrl(routePairingUrl);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Route param overrides pairing atom

Medium Severity

The sync effect builds the pairing URL as params.pairingUrl || connectionPairingUrl, so a stale pairingUrl route param always wins over the global pairing atom. A QR scan or other atom update can be overwritten on the next effect run, resetting host and code to the route value instead of the scanned URL.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8476ef8. Configure here.

defaultEnvironmentHost,
onChangeConnectionPairingUrl,
params.pairingUrl,
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync effect reapplies default host

Medium Severity

Whenever the sync effect runs with an empty parsed host, setHostInput(host || defaultEnvironmentHost) injects the preview default. That differs from the prior setHostInput(host) behavior and can replace a deliberately cleared host (or other local edits) whenever connectionPairingUrl or route params change while the screen stays mounted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8476ef8. Configure here.

throw new Error(`Could not check preview updates (${response.status}).`);
}
const release = parsePersonalPreviewRelease(await response.json());
return selectNewerPersonalPreviewUpdate(Constants.nativeBuildVersion, release);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong latest release breaks updates

Medium Severity

Preview update checks call GitHub’s /releases/latest and then require a mark-mobile-preview-v tag. If the fork’s current “latest” release is anything else, parsing throws and the whole check fails instead of treating it as no update, so Settings and launch checks error until preview is latest again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8476ef8. Configure here.

git -C source checkout --detach "$feature_sha"
git -C source rebase origin/main

source_key="${main_sha}:${feature_sha}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium workflows/personal-android-preview.yml:91

source_key only includes main_sha and feature_sha, so changes to the preview automation (this workflow or prepare-personal-android-preview.mjs) are ignored by the unchanged-source check. When only the automation changes, scheduled runs keep skipping the build and continue serving an APK built by the old safeguards indefinitely — only a manual force_rebuild corrects it. Include the automation revision (e.g. $GITHUB_SHA) in source_key so automation changes trigger a new build.

-          source_key="${main_sha}:${feature_sha}"
+          source_key="${main_sha}:${feature_sha}:${GITHUB_SHA}"
🤖 Copy this AI Prompt to have your agent fix this:
In file @.github/workflows/personal-android-preview.yml around line 91:

`source_key` only includes `main_sha` and `feature_sha`, so changes to the preview automation (this workflow or `prepare-personal-android-preview.mjs`) are ignored by the unchanged-source check. When only the automation changes, scheduled runs keep skipping the build and continue serving an APK built by the old safeguards indefinitely — only a manual `force_rebuild` corrects it. Include the automation revision (e.g. `$GITHUB_SHA`) in `source_key` so automation changes trigger a new build.

Comment thread .github/workflows/personal-android-preview.yml Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4c95746. Configure here.

Comment thread apps/mobile/app.config.ts
const personalPreviewDefaultEnvironmentHost = (
repoEnv.EXPO_PUBLIC_T3CODE_PERSONAL_PREVIEW_DEFAULT_ENVIRONMENT_HOST ??
repoEnv.T3CODE_PERSONAL_PREVIEW_DEFAULT_ENVIRONMENT_HOST
)?.trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty EXPO_PUBLIC blocks fallback

Medium Severity

The new personal preview configuration incorrectly treats empty or invalid EXPO_PUBLIC_* environment variables as valid. This prevents falling back to legacy T3CODE_* env vars or Constants.expoConfig?.extra for the update API URL and default connection host, disabling these features even when valid fallback configurations exist.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4c95746. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile: make thread archiving one-swipe and recoverable

2 participants