Skip to content

ai-partner: continue-in-tab handoff from peek (re-land with callback pattern) #1464

@CraigBuckmaster

Description

@CraigBuckmaster

Parent epic: #1446 (Amicus — AI Study Partner v1)
Phase: 3 · Size: S · Depends on: #1463 (peek conversation — closed), #1457 (persistence — closed), #1454 (Amicus tab — closed)

Promotes an ephemeral peek conversation into a persistent thread in the Amicus tab. The handoff preserves all messages and navigates the user into the full conversation view.

Status

Files to create

  • app/src/services/amicus/promotePeekToThread.ts — the handoff function
  • app/src/services/amicus/__tests__/promotePeekToThread.test.ts

Files to modify

  • app/src/components/amicus/PeekMiniConversation.tsx — CTA button calls the handoff service
  • app/src/components/amicus/AmicusPeekSheet.tsx — accepts onContinueInTab callback prop, closes peek after handoff completes
  • app/src/components/amicus/AmicusFab.tsx — forwards onContinueInTab to PeekSheet
  • app/App.tsx — provides the onContinueInTab callback via navigationRef

Architecture — callback-based navigation (changed from original #1500)

Do not use getParent().navigate() in the service layer. The original PR #1500 did this:

// ❌ original pattern — leaf component coupled to tab name + tree shape
navigation.getParent()?.navigate('AmicusTab', { screen: 'Thread', params: { threadId } });

That's the exact anti-pattern #1562 / #1563 were trying to address. This re-land fixes it up front: the service returns the new threadId, and the parent chain (PeekSheet → FAB → AppShell) decides how to navigate.

// ✅ new pattern — service is navigation-agnostic
export async function promotePeekToThread(params: {
  peekMessages: PeekMessage[];
  chapterRef?: ChapterRef | null;
}): Promise<string>;  // returns threadId

// AmicusPeekSheet receives a callback prop:
interface AmicusPeekSheetProps {
  onContinueInTab?: (threadId: string) => void;
  // ... existing props
}

// App.tsx provides the concrete navigation:
<AmicusFab
  onContinueInTab={(threadId) =>
    navigationRef.navigate('AmicusTab', { screen: 'Thread', params: { threadId } })
  }
/>

This means:

Handoff function

export async function promotePeekToThread(params: {
  peekMessages: PeekMessage[];      // from #1463 snapshotForPromotion()
  chapterRef?: ChapterRef | null;   // optional — becomes thread's chapter_ref
}): Promise<string>;                // returns new threadId

Flow:

  1. Generate thread title from first user message (truncated to ~50 chars at word boundary; add if longer; fallback "New Amicus conversation" if < 10 chars)
  2. Call createAmicusThread (ai-partner: conversation persistence (user.db) #1457 mutation) with threadId, title, chapterRef
  3. For each message in peekMessages, call appendAmicusMessage with role, content, citations, follow_ups
  4. Return the new threadId

Navigation + peek close are the caller's responsibility (AppShell provides navigation, PeekSheet closes itself on success).

Idempotency + UX

  • CTA shows spinner + disables immediately on tap (prevents double-submit)
  • DB failure: toast "Couldn't save conversation — try again", peek stays open, messages preserved in hook state
  • On success: AppShell's callback fires navigationRef.navigate(...), then PeekSheet animates closed. Scroll position on underlying screen preserved.

Acceptance criteria

  • CTA button appears in peek at turn 3 (inherited from ai-partner: mini-conversation component (inside peek) #1463)
  • Tap creates thread with generated title, writes all messages
  • Navigation lands on AmicusTab/Thread with the new threadId scrolled to bottom
  • Peek sheet closes after navigation
  • Underlying screen state preserved (scroll, focus)
  • Double-tap prevented; CTA shows "Saving…" during save
  • DB failure → toast + peek stays open; messages preserved
  • Unit tests cover: title generation (5 cases), happy path, DB failure path, double-tap
  • Service has zero @react-navigation/native imports
  • No any types; lint clean

Pre-merge validation (new — added from #1504's re-land plan)

This card was previously burned by a TestFlight crash that unit tests did not catch. Before merging:

  • Rentamac local build with this branch applied → install on iOS sim → walk the peek → tap CTA → confirm it actually navigates into the Amicus tab thread view without crash
  • Release-mode build (eas build --profile production on rentamac OR Windows) → on-device or sim → same flow. Release-mode bundling + Hermes + New Arch often behave differently from Jest.
  • Post-DB-download hot path — reproduce the first-launch scenario that crashed build (12): fresh install → DB downloads → immediately open peek and tap CTA. The RN patch at app/patches/react-native+0.81.5.patch should now prevent the rethrow abort, but verify directly.

Out of scope

  • Thread list refresh — automatic via ai-partner: thread management UI #1454's useAmicusThreads hook reactivity
  • Edit-title flow post-handoff — that's a ThreadListScreen action (ai-partner: thread management UI #1454)
  • Refactoring AmicusFab's paywall routing — separate concern, do in this PR only if it falls out of the navigationRef-in-App.tsx pattern naturally; otherwise leave for a followup

References

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions