Skip to content

feat(mobile): revamped mobile experience (MVP)#1761

Open
vdekrijger wants to merge 5 commits intomainfrom
posthog-code/mobile-experience-mvp
Open

feat(mobile): revamped mobile experience (MVP)#1761
vdekrijger wants to merge 5 commits intomainfrom
posthog-code/mobile-experience-mvp

Conversation

@vdekrijger
Copy link
Copy Markdown

@vdekrijger vdekrijger commented Apr 21, 2026

Summary

Extracts the mobile-only work from #1655 into a dedicated PR so the mobile app can ship independently of the desktop/agent changes. The mobile app talks to PostHog HTTP APIs directly — zero cross-package coupling.

What's included

Tasks

  • Rich TaskSessionView with plan status bar, question cards, swipe-to-archive (haptics + reanimated)
  • archivedTasksStore, preferencesStore
  • api.ts hardened with exponential backoff + retry on 5xx/network errors

Chat

  • MarkdownText renderer + richer ToolMessage (tool-specific rendering per ACP tool kind)
  • Voice recording hook rewrite (expo-speech-recognition)
  • Copy-to-clipboard on long-press

Onboarding & platform

  • QrScanModal for dev/PAT login flow
  • OfflineBanner + useNetworkStatus (@react-native-community/netinfo)
  • Settings tab added to bottom nav
  • syntax-highlight util (highlight.js) for code blocks
  • Meep sound cue on task completion

New Expo deps: expo-camera, expo-speech-recognition, @react-native-community/netinfo, highlight.js

Scope boundaries

  • Only apps/mobile/** + pnpm-lock.yaml
  • No changes to apps/code, packages/agent, or shared types

Quality pass

Ran /code-review + /simplify. Fixes applied:

  • 13× console.error → scoped logger (CLAUDE.md compliance)
  • PlanStatusBar key collision fix (${index}-${entry.content})
  • Extracted duplicate formatRelativeTime to lib/format.ts
  • pollTicks.clear() in AppState.active handler (was leaking per-session tick counters across background/foreground cycles)
  • Simplified SwipeableTaskItem.onPanResponderMove (removed typed Animated.event listener that failed to compile; no perf delta since useNativeDriver: false)

Final review grade: A (MVP-ready).

Testing

  • pnpm --filter @posthog/mobile exec tsc --noEmit — clean for all files in this PR. One pre-existing error in ConversationList.tsx:32 on main is unrelated and out of scope.
  • No automated tests — matches mobile app baseline; explicit MVP scope decision. Manual device QA needed before merge.

Follow-ups (not in this PR)

  • Fix pre-existing ConversationList.tsx retry Pressable type
  • First unit tests for taskSessionStore + api.ts retry logic
  • Desktop/agent changes from feat: Improved Mobile Code experience #1655 remain in that PR for separate review

Created with PostHog Code

Ports the mobile-only work from #1655 into a dedicated PR so the mobile
app can ship independently of the desktop/backend changes.

Highlights:
- Task session view with plan status, questions, swipe-to-archive, haptics
- Rich chat surfaces (MarkdownText, richer ToolMessage, voice recording)
- QR scanner + auth flow refinements
- Network status + offline banner, preferences store, archived tasks store
- Syntax highlighting via highlight.js, meep sound cue
- Hardened task API with retry/backoff

No tests added yet (matches existing mobile app baseline). Typecheck
passes for all changes in this PR; one unrelated pre-existing error
remains in ConversationList.tsx on main.

Generated-By: PostHog Code
Task-Id: b2269cdf-fbbc-418e-ad9d-15418735e44a
- Replace 13 console.error calls with scoped logger (CLAUDE.md)
- Fix PlanStatusBar key collision risk: key={entry.content} → `${index}-${entry.content}`
- Extract duplicate formatRelativeTime → shared lib/format.ts
- Clear pollTicks in AppState.active handler to prevent per-session leak

Generated-By: PostHog Code
Task-Id: b2269cdf-fbbc-418e-ad9d-15418735e44a
Ported from #1655. Now that mobile work is active, the deny rules
block iteration.

Generated-By: PostHog Code
Task-Id: b2269cdf-fbbc-418e-ad9d-15418735e44a
@vdekrijger vdekrijger marked this pull request as ready for review April 22, 2026 07:20
@vdekrijger vdekrijger requested a review from jonathanlab April 22, 2026 07:20
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 22, 2026

Comments Outside Diff (2)

  1. apps/mobile/src/features/tasks/api.ts, line 39-48 (link)

    P1 5xx retry detection never fires

    The regex /\b5\d{2}\b/ looks for a numeric status code (e.g. 500) in the error message, but the errors thrown in this file always embed response.statusText (the human-readable phrase, e.g. "Internal Server Error") rather than response.status. As a result, the 5xx branch in isRetryableError never matches, and HTTP server errors are not retried despite the PR explicitly advertising "retry on 5xx" hardening.

    For example, fetchS3Logs throws "Failed to fetch logs: Internal Server Error" for a 500 — the regex finds no 3-digit code and returns false, so the error propagates immediately without any retry attempt.

    Fix: include the numeric status code in the thrown message:

    throw new Error(`Failed to fetch logs: ${response.status} ${response.statusText}`);
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/mobile/src/features/tasks/api.ts
    Line: 39-48
    
    Comment:
    **5xx retry detection never fires**
    
    The regex `/\b5\d{2}\b/` looks for a numeric status code (e.g. `500`) in the error message, but the errors thrown in this file always embed `response.statusText` (the human-readable phrase, e.g. `"Internal Server Error"`) rather than `response.status`. As a result, the 5xx branch in `isRetryableError` never matches, and HTTP server errors are not retried despite the PR explicitly advertising "retry on 5xx" hardening.
    
    For example, `fetchS3Logs` throws `"Failed to fetch logs: Internal Server Error"` for a 500 — the regex finds no 3-digit code and returns `false`, so the error propagates immediately without any retry attempt.
    
    Fix: include the numeric status code in the thrown message:
    
    ```ts
    throw new Error(`Failed to fetch logs: ${response.status} ${response.statusText}`);
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. apps/mobile/src/features/tasks/api.ts, line 112-113 (link)

    P2 Stray console.error not converted to scoped logger

    The PR description states 13× console.error calls were migrated to the scoped logger. This console.error in createTask was missed. Replace with logger.error("Create task error", { errorText }) to match the rest of the file.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/mobile/src/features/tasks/api.ts
    Line: 112-113
    
    Comment:
    **Stray `console.error` not converted to scoped logger**
    
    The PR description states 13× `console.error` calls were migrated to the scoped `logger`. This `console.error` in `createTask` was missed. Replace with `logger.error("Create task error", { errorText })` to match the rest of the file.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/api.ts
Line: 39-48

Comment:
**5xx retry detection never fires**

The regex `/\b5\d{2}\b/` looks for a numeric status code (e.g. `500`) in the error message, but the errors thrown in this file always embed `response.statusText` (the human-readable phrase, e.g. `"Internal Server Error"`) rather than `response.status`. As a result, the 5xx branch in `isRetryableError` never matches, and HTTP server errors are not retried despite the PR explicitly advertising "retry on 5xx" hardening.

For example, `fetchS3Logs` throws `"Failed to fetch logs: Internal Server Error"` for a 500 — the regex finds no 3-digit code and returns `false`, so the error propagates immediately without any retry attempt.

Fix: include the numeric status code in the thrown message:

```ts
throw new Error(`Failed to fetch logs: ${response.status} ${response.statusText}`);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/api.ts
Line: 112-113

Comment:
**Stray `console.error` not converted to scoped logger**

The PR description states 13× `console.error` calls were migrated to the scoped `logger`. This `console.error` in `createTask` was missed. Replace with `logger.error("Create task error", { errorText })` to match the rest of the file.

```suggestion
    logger.error("Create task error", { errorText });
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx
Line: 51-113

Comment:
**Stale closure in `panResponder` captures initial prop values forever**

`PanResponder.create` runs exactly once (inside `useRef`), so `isArchived`, `onArchive`, `onUnarchive`, `task`, `onSwipeStart`, and `onSwipeEnd` are all frozen at mount time. If `isArchived` changes after mount, the gesture handler will silently trigger the wrong action (archive instead of unarchive, or vice versa).

The standard fix is a `propsRef` kept current via `useEffect`:

```ts
const propsRef = useRef({ isArchived, onArchive, onUnarchive, onSwipeStart, onSwipeEnd, task });
useEffect(() => {
  propsRef.current = { isArchived, onArchive, onUnarchive, onSwipeStart, onSwipeEnd, task };
});
// then inside onPanResponderRelease read from propsRef.current
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "chore: allow Claude to read/edit apps/mo..." | Re-trigger Greptile

Comment thread apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx
…, logger

- api.ts: introduce HttpError class carrying response.status; update
  isRetryableError to duck-type on `.status` so 5xx errors actually
  retry (regex on statusText never matched "Internal Server Error").
  All 10 throw sites migrated.
- api.ts: replace stray console.error in createTask with scoped logger
  (missed in earlier sweep because api.ts wasn't in the original set).
- SwipeableTaskItem: route props through propsRef so gesture callbacks
  read current values. Was latent only because FlatList keyExtractor
  remounts on isArchived change, but guards against future drift.

Generated-By: PostHog Code
Task-Id: b2269cdf-fbbc-418e-ad9d-15418735e44a
@vdekrijger
Copy link
Copy Markdown
Author

Thanks @greptile-apps — addressed all three in 09a417e:

P1 · 5xx retry detection never fires (api.ts:39-48)
Went with the typed-error approach (1B) since it's mobile-only. Added an HttpError class carrying response.status, migrated all 10 throw new Error(...) sites to use it, and reworked isRetryableError to duck-type on .status >= 500 (falls back to network/timeout string matching for non-HTTP errors). CloudCommandError already has the same shape so it's picked up automatically if ever wrapped in withRetry.

P2 · Stray console.error (api.ts:112-113)
Good catch — this one slipped through the earlier sweep because api.ts wasn't in my initial grep set. Replaced with log.error("Create task error", errorText) using the now-scoped logger.

P2 · Stale closure in SwipeableTaskItem — replied inline.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 22, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Line: 114

Comment:
**`_handleEvent` is dead code**

`_handleEvent` is declared in the `TaskSessionStore` interface and implemented in the store, but a codebase-wide search finds it is never called — not by any component, hook, or internal store method. The polling path batches events directly via `set()`, making this method redundant.

Removing it satisfies the "no superfluous parts" rule and avoids confusion about whether there is an alternative, non-polling event ingestion path.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/mobile/src/features/chat/hooks/useVoiceRecording.ts
Line: 122-134

Comment:
**Transcribing state is unrecoverable if speech engine goes silent**

`stopRecording` resolves only when `ExpoSpeechRecognitionModule` fires a `result` (isFinal), `error`, or `end` event after `stop()`. If the underlying engine fails silently (observed on some Android devices when backgrounding mid-recognition), none of those events fire and the returned Promise never resolves.

The Composer's `handleMicPress` awaits this Promise, leaving the component permanently stuck in the "Transcribing…" visual state. Crucially, the long-press cancel path is gated on `isRecording`, so once `status === "transcribing"` there is no user-accessible escape.

Adding a timeout — e.g. `Promise.race([recognizerPromise, timeout(5000, null)])` — and calling `cleanup()` + `setStatus("idle")` on expiry would prevent the lock-out.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/stores/archivedTasksStore.ts
Line: 17-40

Comment:
**Archived task IDs accumulate indefinitely**

`archivedTasks` stores IDs keyed by timestamp and is persisted to `AsyncStorage`. There is no eviction path for tasks that have since been deleted from the backend. Over time (and especially for power users who archive-then-delete tasks regularly) the storage entry grows without bound.

Consider pruning entries whose IDs no longer appear in the task list, or capping the store to the N most recently archived IDs, before the data size becomes noticeable.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(mobile): address Greptile review — r..." | Re-trigger Greptile

- taskSessionStore: remove dead _handleEvent method + interface entry.
  Polling writes events directly; no caller exists.
- useVoiceRecording: guard stopRecording with a 5s timeout. Some
  Android engines go silent when backgrounded mid-recognition and
  never fire result/error/end, which previously locked the UI on
  "Transcribing…" with no user escape.
- archivedTasksStore: cap persisted archived IDs at 100 (evict oldest
  by timestamp) so AsyncStorage can't grow without bound.

Generated-By: PostHog Code
Task-Id: b2269cdf-fbbc-418e-ad9d-15418735e44a
@vdekrijger
Copy link
Copy Markdown
Author

Round 2 addressed in 5ed95b2:

_handleEvent is dead code (taskSessionStore.ts:114) — Deleted the interface entry + implementation (~15 lines). Polling writes events directly via set(); no caller exists anywhere in the repo.

Transcribing state unrecoverable (useVoiceRecording.ts:122-134) — Added a 5-second timeout inside stopRecording. If none of result/error/end fire (the Android-backgrounded case), the timeout calls cleanup(), sets status back to idle, and resolves with whatever interim transcript was captured (or null). Listener-side resolutions clear the timeout to avoid double-resolve. Noted as a follow-up: also relax Composer's cancel guard so users can tap out of transcribing manually — but the timeout alone prevents the lock-out.

archivedTasks unbounded growth (archivedTasksStore.ts:17-40) — Went with the cap option (at 100, per repo preference). Every archive() call runs through withCap() which sorts entries by descending timestamp and drops anything past the 100th. Chose the cap over the prune-on-fetch approach because getTasks() accepts filters, so pruning against the current list would wrongly delete archived IDs for filtered-out tasks (user flips filter, tasks "unarchive" themselves). Cap is simpler and addresses the storage-size concern directly.

@vdekrijger vdekrijger enabled auto-merge (squash) April 22, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant