refactor(mobile): unify chat + project into one persistent ChatSurface (#12687)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SubashMohanandClaude Opus 4.8 authored and GitHub committed 2026-07-06 16:40:21 +00:00
1 parent 36c49a8d1d
commit 7a35a3a293
14 files changed
+542 -230

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ Port the Onyx web chat experience to the React Native + Expo mobile app, deliver
3. [03-detailed-design.md](03-detailed-design.md) — client data model (no DB change), shared-contract inventory, new files, file tree, renderer-registry foundation, pre-impl notes.
4. [04-implementation-plan.md](04-implementation-plan.md) — CLAUDE.md-format plan + appended **Plan Challenge** results (all checks pass; claims web-verified).
5. [05-pr-roadmap.md](05-pr-roadmap.md) — the delivery sequence: PR 0 (spikes) → 1 (shell) → 2 (**mobile-native chat data layer**: parser + contracts + tree + history) → 3 (**core chat** + renderer-dispatch foundation) → 4 (resume) · 5 (agents) · 6→7→8 (projects → files → attachments) · 9a–9e (rich-chat). Each PR has a **"Before you start (grill on)"** checklist.
6. [06-unified-chat-surface.md](06-unified-chat-surface.md) — post-PR 6 refactor (`unify-chat-input`): collapse the forked `ChatConversation` + `ProjectView` chrome into **one persistent `ChatSurface`** in `(app)/_layout.tsx` so new/chat/project morph in place (no screen swap), composer declared once. Includes the `renderRouter` spike result (PASS) and a grill-on checklist.
> **Decision (2026-06-26, revised): no shared chat code.** The chat pure layer — NDJSON parser, message tree, `processRawChatHistory`, and all chat/streaming/file contracts — is written **natively in mobile** (PR 2), with web keeping its own existing copies. **Nothing chat-related enters `@onyx-ai/shared`.** We considered sharing the whole pure layer, then just the ~40-line parser, then dropped even that — the shared-package machinery (util + web re-point + jest mapper + dist coupling) is more moving parts than the ~200 lines of duplication it removes. Pre-production the backend protocol is stable, so drift risk is low and cheap to re-extract later if it bites. **Web is untouched by the mobile chat port.** Full rationale in the **PR 2** section of `05-pr-roadmap.md`.
+11 -2
View File
@@ -175,11 +175,20 @@ PR 3 is the spine; PR 4/5/6 and all of PR 9 fan out from it independently. PR 7
- **Drift checkpoint:** Re-confirm "no project CRUD" still holds.
- **As built (2026-07-01, web-faithful + read-only — grilled before coding):** All four gates chosen web-faithful (see [[mobile-chat-pr6-decisions]]): **(1) nav** = a sidebar **"Projects"** section (above Recents) + a `(app)/projects/[id].tsx` detail screen; **no** standalone `projects/index.tsx` list (web has none). **(2) detail** = full read-only parity — folder title + instructions + files-with-indexing-status + input bar (starts a project-scoped chat) + the project's chats. **(3) chats** read from the embedded `chat_sessions[]` on `GET /user/projects/{id}/details` (no separate paginated fetch). **(4) MMKV** — all project query keys excluded (their embedded chat titles are PII). New files: `chat/contracts/projects.ts` (mobile-native `Project`/`ProjectFile`/`UserFileStatus`/`ProjectDetails`; `chat_sessions` reuses `ChatSessionSummary`), `api/chat/projects.ts` (`useProjects` plain `useQuery` over the unpaginated `/user/projects`; `useProjectDetails` over `/details`), `app/(app)/projects/[id].tsx`, `components/chat/{ProjectView,ProjectContextPanel,ProjectChatSessionList,ProjectList,FileCard}.tsx`, `icons/file-text.tsx`, `lib/time.ts` (`timeAgo`). Modified: `components/chat/AppSidebar.tsx` (Projects section + `useSegments()` disambiguation of the shared `id` param between `/chat/[id]` and `/projects/[id]`), `hooks/useChatController.ts` (new `projectId` param → `createChatSession(DEFAULT_PERSONA_ID, projectId)`; invalidates the project queries after a project-scoped create; **push (not replace) into `/chat/[id]` from a project so Back returns to it**), `api/chat/sessions.ts` (export `DEFAULT_PERSONA_ID`), `api/query-keys.ts` (`userProjects`/`userProject`), `query/client.ts` (exclude both project key heads). **~528 source LOC** (in band). **Web-parity divergences (intentional):** no project/file/chat CRUD or move/delete menu (create/rename/delete = later; file add/remove = PR 7); input bar sticks to the bottom (web keeps it mid-page — platform norm); single `folder` glyph, no inline folder expand in the sidebar; file rows are read-only pills with a spinner while indexing — **no image thumbnails** (auth-image `<Image>` bearer is PR 8); extension-less files show "File" where web shows a blank type line. Verified: mobile `tsc` clean, lint clean, **145 jest pass** (16 new: ProjectList + ProjectChatSessionList + projects hooks + controller project-scope/push + `timeAgo` boundary + MMKV project exclusion). Adversarial multi-agent review = 7 findings, 6 confirmed & fixed (`timeAgo` "0y ago" year-boundary gap; `replace`→`navigate` from a project; NaN-id `enabled` guard; missing list loader; +1 dup) / 1 accepted nit (the "File" label). **HARD GATE STILL OWED:** on-device run vs a live backend (agent can't run a device build) — owner must verify the sidebar Projects section, project detail render, and starting a project-scoped chat.
## Post-PR 6 refactor — `unify-chat-input` (structural; affects PR 7–9 targets)
After PR 6, the forked `ChatConversation` + `ProjectView` were collapsed into one persistent **`ChatSurface`** (mounted in `(app)/_layout`, driven by `deriveFocus(pathname)`; full spec in [06-unified-chat-surface.md](06-unified-chat-surface.md)). The route files (`index`, `chat/[id]`, `projects/[id]`) now render **null** — the chat/project UI is drawn by the overlay, and the **composer is a single persistent `InputBar`** in `ChatSurface`. This shifts the targets below:
- **Project detail UI lives in `components/chat/ProjectContextPanel.tsx`** (rendered by `ChatSurface` in project focus), **not** `projects/[id].tsx` (PR 7).
- The **composer is declared once** — attachment UI/state wires into `ChatSurface`'s composer, not per-screen (PR 8).
- **Watch-out:** `ChatSurface` never remounts across conversations, so **any per-conversation draft state must reset on `[sessionId, projectId]` change**. The input draft already does (a `useEffect` in `ChatSurface`); **PR 8 attachments must follow the same rule** or they leak into the wrong conversation.
- **PR 9 is unaffected** — rich renderers still plug into `MessageList` via the PR 3 registry; the refactor changed the screen shell, not the message display path.
## PR 7 — Project file management
- **Goal:** Add documents/photos to a project, watch indexing, and remove them.
- **Scope (in):** `api/files/upload.ts` (`expo-file-system` `createUploadTask`, MULTIPART, field `files`, bearer, `onProgress`); `state/uploadStore.ts` + 3s status polling; `expo-document-picker` + `expo-image-picker` entry points + asset normalization; link/unlink; file list UI with status chips. app config plugin entries (photo permission strings).
- **Out of scope:** Per-message attachments (PR 8); camera.
- **Files:** `api/files/upload.ts` (new), `state/uploadStore.ts` (new), picker helpers (new), `projects/[id].tsx` (modified: files section), `app.json`/config plugin (modified).
- **Files:** `api/files/upload.ts` (new), `state/uploadStore.ts` (new), picker helpers (new), `components/chat/ProjectContextPanel.tsx` (modified: add/remove files section — the project detail renders inside `ChatSurface`, so `projects/[id].tsx` is now a null route and is **not** touched), `app.json`/config plugin (modified).
- **Est. size:** ~650 LOC.
- **Depends on:** PR 6.
- **Feature-flag state:** N/A.
@@ -192,7 +201,7 @@ PR 3 is the spine; PR 4/5/6 and all of PR 9 fan out from it independently. PR 7
- **Scope (in):** Reuse PR 7 pickers/uploader from the input bar; `components/chat/AttachmentChips.tsx`; `mobile/src/chat/fileDescriptors.ts` (mobile-native `projectFilesToFileDescriptors` + type detection — per the PR 2 minimal-sharing decision, **not** shared; web keeps its own `fileUtils.ts`); build `file_descriptors[]` on send; **gate send until indexed** (`token_count != null`); image preview via `GET /api/chat/file/{file_id}`. Camera deferred.
- **Web parity (REQUIRED — owner-enforced 2026-06-30):** this PR must bring the **input bar to full web parity**. PR 3 shipped a deliberately minimal single-line input; PR 8 reworks it anyway for attachments, so restyle `InputBar.tsx` here to web's **composer** shape/layout — the rounded, auto-growing multi-line container with the control/toolbar row and the send/stop affordance positioned as in web's `BaseInputBar`/`AppInputBar`, attachment chips slotted in. The mobile input must **look and behave like web's composer** (small platform diffs OK), per the WEB-PARITY PRINCIPLE. Re-read `web/src/sections/input/{BaseInputBar,AppInputBar}.tsx` first; if a needed piece isn't a mobile primitive yet, ask before porting.
- **Out of scope:** Camera capture.
- **Files:** `mobile/src/chat/fileDescriptors.ts` (new, mobile-native), `components/chat/AttachmentChips.tsx` (new), `InputBar.tsx` (modified), `useChatController.ts` (modified: attach descriptors). Web `fileUtils.ts` **untouched**.
- **Files:** `mobile/src/chat/fileDescriptors.ts` (new, mobile-native), `components/chat/AttachmentChips.tsx` (new), `InputBar.tsx` (modified, web-parity restyle), `useChatController.ts` (modified: attach descriptors + **reset the attachment draft on `[sessionId, projectId]` change**, mirroring the input-draft clear — the composer is one persistent instance in `ChatSurface`, so unreset attachments leak across conversations). Web `fileUtils.ts` **untouched**.
- **Est. size:** ~550 LOC.
- **Depends on:** PR 7.
- **Feature-flag state:** N/A.
+259
View File
@@ -0,0 +1,259 @@
> Status: implemented + adversarial-reviewed (2 real regressions fixed) — local gate green (typecheck · lint · 198 jest); **on-device visual gate pending** · Task: mobile-chat · Phase: `unify-chat-input` (post-PR 6 refactor)
> **Adversarial review (2026-07-06):** 5 dimension reviewers + 2 skeptics/finding. Two real, refactor-introduced regressions found and FIXED: (1) unsent composer draft leaked across conversations via the shared persistent `useChatController` — now cleared on `[sessionId, projectId]` change; (2) cold-open of an existing chat flashed the welcome/empty state through the new cross-fade — empty branch now gated on `!isHydrating`. A third finding (body not keyed by `sessionId`) was adjudicated **not a code regression** (`MessageList` unchanged, reuse pre-existed, scroll-reset already deferred to the on-device gate) — corrected the spec prose instead. The chat↔chat draft-persist case was refuted as pre-existing (same `chat/[id]` route reused before too).
# Mobile Chat — Unified Chat Surface (single persistent screen)
## Problem
After PR 6 (projects), the chat **input and chrome are forked across two components**:
- `components/chat/ChatConversation.tsx` renders the chrome via `components/chat/ChatScreen.tsx`
(`SafeAreaView` + `ChatHeader` + `KeyboardStickyView` + `InputBar`), used by `/` and `/chat/[id]`.
- `components/chat/ProjectView.tsx` **hand-re-implements that same chrome inline** (`ProjectView.tsx:47‑85`)
instead of reusing `ChatScreen`, used by `/projects/[id]`.
Consequences:
1. The composer (`InputBar`) and its wiring (`value/onChangeText/onSend/onStop/chatState`) are written
**twice** and will drift.
2. **One conversation is rendered by two different code paths** — sending inside a project keeps you on
`/projects/[id]` (ProjectView's in-place `activeSessionId` swap), but reopening that same chat from the
sidebar lands on `/chat/[id]` (ChatScreen). Two shells for the same thing.
## Requirement (owner, locked)
Every mode change must feel like **the same screen with its content morphing** — **not** a navigation to a
next screen:
- **empty chat → chat with a message** (first send from the landing)
- **project home → chat** (first send inside a project)
- **populated chat → empty/new chat** (the "new chat" action)
- (implied) **switching between two conversations**
Plus: **tapping the input lifts the composer above the keyboard** (already true — `ChatScreen.tsx:21`
`KeyboardStickyView`; preserved).
This supersedes the earlier "navigate to `/chat/[id]` (web-parity)" call from the design discussion: we
still change the URL (for deep-links + back-stack), but because the surface never unmounts it **reads as
in-place**, not a swap. Best of both.
## Decision & evidence
**Adopt a single persistent `ChatSurface` mounted in `(app)/_layout.tsx`**, a sibling to `<Stack>`, driven
by a **focus** derived from the route. Every transition is a `router` navigation that changes the focus; the
surface **re-renders and morphs in place — it does not remount**.
This is the same mechanism `AppSidebar` already relies on in production: it is a sibling to `<Stack>` in
`(app)/_layout.tsx` and survives every screen change. It also mirrors web's `AppPage`, which never remounts
across new/chat/project — the URL change is just an `appFocus` flip.
**Spike (headless, `expo-router/testing-library` `renderRouter`) — PASS.** Because "mount vs re-render" is
pure React reconciliation, jest models it faithfully. Verified:
| Driven | Result |
| --- | --- |
| `/` → `/chat/[id]` → `/projects/[id]` → `router.back()`, surface in `_layout` | Surface **never remounted** (mount count constant) yet **re-rendered to the new focus** each hop; back-stack worked |
| `router.setParams({chatId})` on one route | Screen **did not remount**; focus flipped in place |
The one thing jest cannot prove is visual (morph smoothness, keyboard-lift, no flash) — deferred to the
on-device gate (below).
## How it works (as implemented)
**Realization chosen: the surface is an absolute-fill overlay _above_ `<Stack>` that renders `null` on
non-chat routes** — not transparent Stack screens. This resolves grill Q1 without depending on
react-native-screens transparency/touch behavior: it's the same overlay model `AppSidebar` already uses.
```
(app)/_layout.tsx → <View flex-1>
┌─────────────────────────────────────────────────────────────┐
│ <Stack/> ← drives URL + back-stack. Chat routes │
│ (index, chat/[id], projects/[id]) render null (URL only). │
│ Real destinations (agents) render normally, opaque. │
├─────────────────────────────────────────────────────────────┤
│ <ChatSurface/> ← absolute overlay ON TOP of the Stack. │
│ focus = deriveFocus(usePathname()) │
│ · focus null (agents) → renders null, Stack shows through │
│ · focus set → StyleSheet.absoluteFill overlay: │
│ ChatSurfaceContent stays mounted across new↔chat↔ │
│ project (focus never null there) → header + composer │
│ persist; body/aux cross-fade (reanimated) │
├─────────────────────────────────────────────────────────────┤
│ <AppSidebar/> ← unchanged; Portal → above the overlay │
└─────────────────────────────────────────────────────────────┘
```
A navigation (`router.replace('/chat/x')` from the landing, `router.navigate('/chat/x')` from a project,
`router.navigate('/projects/7')`, `router.replace('/')`) changes the URL → `ChatSurface` re-derives focus →
`ChatSurfaceContent` gets a new `focus` prop **without remounting**, so the header + composer stay put and only
the body/aux regions animate. Because the overlay is opaque and covers the (null) Stack chat screens, there is
nothing behind it to flash. `useChatController` is **unchanged** — the caller simply omits `onSessionCreated`,
taking the hook's existing `projectId != null ? navigate : replace` branch.
Trade-off of the overlay: the iOS edge-swipe-back gesture is covered while a chat route is active (back is via
header/sidebar instead) — acceptable and matching web, which has no swipe-back.
## Focus model
A pure function `chat/chatFocus.ts` maps the route to a focus (testable, no native deps):
```ts
type ChatFocus =
| { kind: "new"; sessionId: null; projectId: null }
| { kind: "chat"; sessionId: string; projectId: null }
| { kind: "project"; sessionId: null; projectId: number };
deriveFocus(pathname): ChatFocus | null // null → non-chat route (surface renders nothing)
```
Implemented over `usePathname()` (single source, race-free vs. splitting across segments + params): `"/"` →
`new`; `"/chat/<id>"` → `chat`; `"/projects/<n>"` → `project` (non-numeric / empty id → `null`); anything else
(`/agents`, …) → `null`.
| Route | pathname | focus.kind | Surface content (above / below) |
| --- | --- | --- | --- |
| `/` | `/` | `new` | empty-state (`ChatEmptyState`) / — |
| `/chat/[id]` | `/chat/<id>` | `chat` | `MessageList` / — |
| `/projects/[id]` | `projects` | `project` | `ProjectContextPanel` / `ProjectChatSessionList` |
(The derivation reuses exactly the `useSegments()` + `useGlobalSearchParams()` disambiguation already in
`AppSidebar.tsx:22‑29`.)
## The four morphs
The **chrome (`ChatHeader`) and the composer (`InputBar`) are one persistent instance** across all of these;
only the body/aux regions animate.
| Transition | Trigger | Persists | Animates |
| --- | --- | --- | --- |
| empty → chat | first send from `/` → `router.replace('/chat/x')` | header, composer | empty-state fades out, `MessageList` fades in (optimistic user bubble already seeded in the store) |
| project → chat | first send in `/projects/[id]` → `router.navigate('/chat/x')` | header, composer | context panel + session-list fade out, `MessageList` fades in, composer slides to bottom (`LinearTransition`) |
| populated → new | "new chat" → `router.replace('/')` | header, composer | `MessageList` fades out, empty-state fades in |
| chat A → chat B | sidebar select → `router.navigate('/chat/b')` | header, composer | `MessageList` data swaps in place (unsent draft is cleared) |
## Detailed design
### New
- **`mobile/src/chat/chatFocus.ts`** — `deriveFocus(segments, params) → ChatFocus` (pure). Unit-tested.
- **`mobile/src/components/chat/ChatSurface.tsx`** — the persistent host. Derives focus, calls **one**
`useChatController`, renders the slotted shell, owns the morph animations. This is the merge target for
`ChatConversation` + `ProjectView`.
### Changed
- **`mobile/src/components/chat/ChatScreen.tsx`** — evolve into the slotted shell: add an optional
**`below?: ReactNode`** slot (rendered as a sibling after the `KeyboardStickyView`), used only by project
focus. `above` = `children`, `composer` = `input` (unchanged). `CenteredContent` stays.
- **`mobile/src/app/(app)/_layout.tsx`** — mount `<ChatSurface/>` as a sibling to `<Stack>` (alongside
`<AppSidebar/>`). Configure the surface-backed routes (`index`, `chat/[id]`, `projects/[id]`) as
transparent/`animation:"none"` so the surface shows through without a swap; leave real destinations
(`agents`, future settings) as normal screens.
- **`mobile/src/app/(app)/index.tsx`**, **`chat/[id].tsx`**, **`projects/[id].tsx`** — collapse to null
focus-reporter screens (they exist only for the URL + back-stack; the surface renders the content). The
param parsing that used to live in `projects/[id].tsx` moves into `deriveFocus`.
- **`mobile/src/hooks/useChatController.ts`** — **logic unchanged.** Only the *caller* changes: `ChatSurface`
stops passing `onSessionCreated`, so the controller takes its existing default branch
(`projectId != null ? router.navigate : router.replace`, `useChatController.ts:274‑287`). That single branch
already gives us: project first-send → push (back returns to project), landing first-send → replace (back
skips the empty landing). No new controller code.
### Deleted
- **`mobile/src/components/chat/ChatConversation.tsx`** and **`ProjectView.tsx`** — merged into `ChatSurface`.
ProjectView's `activeSessionId` / `started` / `onSessionCreated` in-place machinery is removed; the
persistent surface makes the plain navigate feel in-place.
### Reused unchanged
`ChatHeader`, `InputBar`, `ChatEmptyState`, `MessageList`, `ProjectContextPanel`, `ProjectChatSessionList`,
`useLiveAgent`, `useChatSessionController`, the zustand `chatSessionStore`, and the module-scope
`runChatStream` (already survives navigation — it keeps writing by `sessionId`).
### File tree (delta)
```
mobile/src/
├── app/(app)/
│ ├── _layout.tsx (M) mount <ChatSurface/> sibling; route transparency
│ ├── index.tsx (M) → null focus-reporter
│ ├── chat/[id].tsx (M) → null focus-reporter
│ └── projects/[id].tsx (M) → null focus-reporter (param logic → deriveFocus)
├── chat/
│ └── chatFocus.ts (A) deriveFocus(segments, params)
├── components/chat/
│ ├── ChatSurface.tsx (A) persistent host (merges the two below)
│ ├── ChatScreen.tsx (M) + below? slot
│ ├── ChatConversation.tsx (D)
│ └── ProjectView.tsx (D)
└── hooks/useChatController.ts (M) caller drops onSessionCreated (no logic change)
```
## Back-stack & URL semantics (web-faithful, free from the router)
- **project → chat**: `router.navigate` (push) → back returns to the project home. Matches web (chatId over
projectId).
- **landing → chat**: `router.replace` → back skips the now-empty landing.
- **new chat**: `router.replace('/')`.
- **switch conversation** (sidebar): `router.navigate('/chat/b')`.
The body is **not** keyed by `sessionId` — switching between two warm conversations reuses the same
`MessageList`/`FlashList` (data swaps in place, as it did pre-refactor), so there is no automatic scroll-reset.
Per-conversation scroll-reset is deferred to the on-device gate (open question below). The chrome + composer
never remount; the composer's unsent draft is cleared when the target conversation changes so it can't be sent
into the wrong session.
## Morph implementation (reanimated)
- Body region: cross-fade keyed by `focus.kind` / `sessionId` (`FadeIn` / `FadeOut`, ~150 ms — reuse
ProjectView's `TRANSITION_MS`).
- Composer: `LinearTransition` so it slides when the project below-slot collapses (already proven in
`ProjectView.tsx:74`).
- Project below-slot (`ProjectChatSessionList`): `FadeOut` on leaving project focus.
## Edge cases / risks
- **Overlay coverage (on-device).** The opaque overlay covers the null Stack chat screens, so there is nothing
behind it to flash. Confirm the overlay sits correctly under the sidebar Portal and that focus→null (agents)
cleanly reveals the Stack screen.
- **Switching conversations mid-stream.** `runChatStream` is module-scope and keyed by `sessionId`; the store
is per-session — the outgoing stream keeps writing to its session while the surface shows another. Already
the case today; the persistent surface doesn't change it.
- **Deep-link / cold start** into `/chat/[id]` or `/projects/[id]`: `deriveFocus` yields the right mode on
first frame; existing `useChatController` hydration query fills the body.
- **Keyboard-lift**: composer stays in `KeyboardStickyView` inside the persistent surface — unchanged.
- **Agents gallery / settings**: remain normal opaque Stack screens over the surface; a swap feel there is
correct (they are genuinely different destinations, not a chat mode).
## Tests
- **Unit (jest, no native) — DONE** — `chat/__tests__/chatFocus.test.ts`: `deriveFocus(pathname)` for `/`,
`/chat/<id>`, `/projects/<n>`, non-numeric/empty ids → `null`, and non-surface routes (`/agents`,
`/settings/...`) → `null`. 6 cases, part of the 198-test suite.
- **Integration (`renderRouter`) — DEFERRED** — the spike already proved the persistence pattern headlessly;
a kept guard against the real `(app)/_layout` is impractical under jest because importing `ChatSurface` pulls
reanimated + FlashList (the "Worklets not initialized" barrel crash `mobile/CLAUDE.md` warns about). Revisit
if we add a jest-safe seam.
- **Existing suite — GREEN** — no `ChatConversation`/`ProjectView` tests existed to migrate; the presentational
`ChatSessionList` / `ProjectChatSessionList` / `ProjectList` tests are unaffected. Full suite: typecheck ·
lint (0 errors) · **198 jest pass**.
- **On-device (HARD GATE, owner-run) — PENDING** — the four morphs read as in-place (no screen swap), composer
lifts on focus, overlay reveals agents cleanly, back-stack returns project → chat → project.
## What changes / what's untouched
- **Backend**: none. **Web**: none (mobile-only refactor). **Mobile**: routing model + the two chat screens
restructured into one persistent surface; net **deletion** of duplicated chrome.
## Open questions (were grill-on; settle at the on-device gate)
1. **Overlay vs `<Slot>`** — RESOLVED to the opaque overlay (see "How it works"); revisit only if the on-device
pass shows a coverage/gesture issue.
2. **New-chat URL** — kept `/` for `new` (simplest, unchanged).
3. **Message-body key granularity** — `MessageList` currently keys on the `focus.kind`/`messages` swap; confirm
scroll-reset expectation when returning to an existing chat feels right on device.
4. **Morph parity with web** — implemented as a 150 ms cross-fade (reusing the old `ProjectView` `TRANSITION_MS`);
confirm against web's curve/duration.
5. **Agents gallery** — stays a separate opaque screen; `deriveFocus('/agents') → null` so the surface hides.
6. **Non-sidebar entry into chat** (notifications, deep links) — re-check against the overlay model on device.
+7 -3
View File
@@ -1,13 +1,17 @@
import { View } from "react-native";
import { Stack } from "expo-router";
import { AppSidebar } from "@/components/chat/AppSidebar";
import { ChatSurface } from "@/components/chat/ChatSurface";
// Sidebar mounted here (not per-screen) so its overlay spans every (app) screen.
// ChatSurface overlays the <Stack> as one persistent surface so chat routes morph in place;
// the Stack drives URLs/back-stack (chat routes render null). See docs/mobile-chat/06-unified-chat-surface.md.
export default function AppLayout() {
return (
<>
<View className="flex-1">
<Stack screenOptions={{ headerShown: false }} />
<ChatSurface />
<AppSidebar />
</>
</View>
);
}
+1 -8
View File
@@ -1,8 +1 @@
import { useLocalSearchParams } from "expo-router";
import { ChatConversation } from "@/components/chat/ChatConversation";
export default function ChatSessionScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return <ChatConversation sessionId={id} />;
}
export { default } from "@/components/chat/SurfaceRoute";
+1 -6
View File
@@ -1,6 +1 @@
import { ChatConversation } from "@/components/chat/ChatConversation";
// New-chat landing; first send creates a session and navigates to /chat/[id].
export default function NewChatScreen() {
return <ChatConversation sessionId={null} />;
}
export { default } from "@/components/chat/SurfaceRoute";
+1 -12
View File
@@ -1,12 +1 @@
import { useLocalSearchParams } from "expo-router";
import { ProjectView } from "@/components/chat/ProjectView";
export default function ProjectScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// A malformed route (e.g. /projects/abc) → null, so the chat controller never
// gets NaN and silently creates an unscoped chat.
const parsed = Number(id);
const projectId = Number.isFinite(parsed) ? parsed : null;
return <ProjectView projectId={projectId} />;
}
export { default } from "@/components/chat/SurfaceRoute";
@@ -0,0 +1,43 @@
import { describe, expect, it } from "@jest/globals";
import { deriveFocus } from "@/chat/chatFocus";
describe("deriveFocus", () => {
it("maps / to the new-chat focus", () => {
expect(deriveFocus("/")).toEqual({
kind: "new",
sessionId: null,
projectId: null,
});
});
it("maps /chat/<id> to a chat focus", () => {
expect(deriveFocus("/chat/abc-123")).toEqual({
kind: "chat",
sessionId: "abc-123",
projectId: null,
});
});
it("maps /projects/<n> to a project focus", () => {
expect(deriveFocus("/projects/7")).toEqual({
kind: "project",
sessionId: null,
projectId: 7,
});
});
it("rejects a non-numeric project id", () => {
expect(deriveFocus("/projects/abc")).toBeNull();
});
it("rejects an empty chat or project id", () => {
expect(deriveFocus("/chat/")).toBeNull();
expect(deriveFocus("/projects/")).toBeNull();
});
it("returns null for non-surface routes (e.g. the agents gallery)", () => {
expect(deriveFocus("/agents")).toBeNull();
expect(deriveFocus("/settings/profile")).toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
// Maps pathname → ChatSurface focus; null = non-chat route (surface hides). Pure for tests.
export type ChatFocus =
| { kind: "new"; sessionId: null; projectId: null }
| { kind: "chat"; sessionId: string; projectId: null }
| { kind: "project"; sessionId: null; projectId: number };
const CHAT_PREFIX = "/chat/";
const PROJECT_PREFIX = "/projects/";
export function deriveFocus(pathname: string): ChatFocus | null {
if (pathname === "/") {
return { kind: "new", sessionId: null, projectId: null };
}
if (pathname.startsWith(CHAT_PREFIX)) {
const id = firstSegment(pathname.slice(CHAT_PREFIX.length));
return id ? { kind: "chat", sessionId: id, projectId: null } : null;
}
if (pathname.startsWith(PROJECT_PREFIX)) {
const raw = firstSegment(pathname.slice(PROJECT_PREFIX.length));
const projectId = Number(raw);
// Number("") is a finite 0; require a non-empty id.
return raw && Number.isFinite(projectId)
? { kind: "project", sessionId: null, projectId }
: null;
}
return null;
}
function firstSegment(rest: string): string {
const slash = rest.indexOf("/");
return slash === -1 ? rest : rest.slice(0, slash);
}
@@ -1,78 +0,0 @@
// Shared by the new-chat landing (sessionId=null) and an open session (/chat/[id]).
import { useLocalSearchParams } from "expo-router";
import { useChatSessions } from "@/api/chat/sessions";
import { ChatScreen } from "@/components/chat/ChatScreen";
import { ChatEmptyState } from "@/components/chat/ChatEmptyState";
import { UNNAMED_CHAT } from "@/components/chat/ChatSessionList";
import { InputBar } from "@/components/chat/InputBar";
import { MessageList } from "@/components/chat/MessageList";
import { DEFAULT_AGENT_ID } from "@/chat/agents";
import { useChatController } from "@/hooks/useChatController";
import { useChatSessionController } from "@/hooks/useChatSessionController";
import { useLiveAgent } from "@/hooks/useLiveAgent";
interface ChatConversationProps {
sessionId: string | null;
}
export function ChatConversation({ sessionId }: ChatConversationProps) {
const { sessions } = useChatSessions();
const { agentId: agentIdParam } = useLocalSearchParams<{
agentId?: string;
}>();
const session = sessionId
? sessions.find((chatSession) => chatSession.id === sessionId)
: undefined;
// Landing: agent from the route param. Existing session: its creation-time persona wins.
const parsedAgentId = agentIdParam != null ? Number(agentIdParam) : NaN;
const selectedAgentId = Number.isNaN(parsedAgentId) ? null : parsedAgentId;
const liveAgent = useLiveAgent(selectedAgentId, session?.persona_id ?? null);
// Back-stop with the explicit route selection before agents resolve, so a fast send can't
// create the session with the default persona instead of the picked agent.
const personaId = liveAgent?.id ?? selectedAgentId ?? DEFAULT_AGENT_ID;
const isDefaultAgent = personaId === DEFAULT_AGENT_ID;
const { messages, chatState, input, setInput, submit, stop } =
useChatController(sessionId, personaId);
// re-attaches to an in-flight run when opened cold
useChatSessionController(sessionId);
// new chat has no title; opened session shows its name
const title = sessionId
? session?.name && session.name.trim()
? session.name
: UNNAMED_CHAT
: undefined;
return (
<ChatScreen
title={title}
input={
<InputBar
value={input}
onChangeText={setInput}
onSend={() => {
void submit();
}}
onStop={stop}
chatState={chatState}
/>
}
>
{messages.length === 0 ? (
<ChatEmptyState
agent={liveAgent}
isDefaultAgent={isDefaultAgent}
onStarterSelect={(message) => {
void submit(message);
}}
/>
) : (
<MessageList messages={messages} />
)}
</ChatScreen>
);
}
+5 -5
View File
@@ -7,23 +7,23 @@ import { ChatHeader } from "@/components/chat/ChatHeader";
interface ChatScreenProps {
title?: string;
// region above the composer; caller owns its flex (flex-1 MessageList, or capped project panel)
children: ReactNode;
// input bar, omitted for read-only chrome
input?: ReactNode;
below?: ReactNode;
}
// Chat chrome: top-only safe area + header + keyboard-aware input (KeyboardStickyView).
export function ChatScreen({ title, children, input }: ChatScreenProps) {
export function ChatScreen({ title, children, input, below }: ChatScreenProps) {
return (
<SafeAreaView edges={["top"]} className="flex-1 bg-background-neutral-00">
<ChatHeader title={title} />
<View className="flex-1">{children}</View>
{children}
{input ? <KeyboardStickyView>{input}</KeyboardStickyView> : null}
{below}
</SafeAreaView>
);
}
// Centered content with the standard screen gutter (defined once here).
export function CenteredContent({ children }: { children: ReactNode }) {
return (
<View className="flex-1 items-center justify-center px-24">{children}</View>
+177
View File
@@ -0,0 +1,177 @@
import { useEffect } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
} from "react-native-reanimated";
import { router, useGlobalSearchParams, usePathname } from "expo-router";
import { useAgents } from "@/api/chat/agents";
import { useProjectDetails } from "@/api/chat/projects";
import { useChatSessions } from "@/api/chat/sessions";
import { ChatEmptyState } from "@/components/chat/ChatEmptyState";
import { ChatScreen } from "@/components/chat/ChatScreen";
import { UNNAMED_CHAT } from "@/components/chat/ChatSessionList";
import { InputBar } from "@/components/chat/InputBar";
import { MessageList } from "@/components/chat/MessageList";
import { ProjectChatSessionList } from "@/components/chat/ProjectChatSessionList";
import { ProjectContextPanel } from "@/components/chat/ProjectContextPanel";
import { DEFAULT_AGENT_ID } from "@/chat/agents";
import { deriveFocus, type ChatFocus } from "@/chat/chatFocus";
import { useChatController } from "@/hooks/useChatController";
import { useChatSessionController } from "@/hooks/useChatSessionController";
import { useLiveAgent } from "@/hooks/useLiveAgent";
const TRANSITION_MS = 150;
// Persistent chat surface: one absolute overlay mounted in (app)/_layout, morphed in place
// by route → focus so the header/composer never remount. null on non-chat routes (the Stack
// screen shows through). See docs/mobile-chat/06-unified-chat-surface.md.
export function ChatSurface() {
const pathname = usePathname();
const focus = deriveFocus(pathname);
if (!focus) return null;
return (
<View style={StyleSheet.absoluteFill}>
<ChatSurfaceContent focus={focus} />
</View>
);
}
// Never keyed/remounted across new↔chat↔project — only `focus` changes, so the
// chrome/composer persist and the body cross-fades.
function ChatSurfaceContent({ focus }: { focus: ChatFocus }) {
const sessionId = focus.kind === "chat" ? focus.sessionId : null;
const projectId = focus.kind === "project" ? focus.projectId : null;
const isProject = focus.kind === "project";
const { sessions } = useChatSessions();
const { agentId: agentIdParam } = useGlobalSearchParams<{
agentId?: string;
}>();
const session = sessionId
? sessions.find((chatSession) => chatSession.id === sessionId)
: undefined;
// Landing: agent from the route param. Existing session: its creation-time persona wins.
const parsedAgentId = agentIdParam != null ? Number(agentIdParam) : NaN;
// Only a real agent id (non-negative integer) is honored; a bogus deep-link param
// (Infinity, float, negative) falls back to the default persona.
const selectedAgentId =
Number.isInteger(parsedAgentId) && parsedAgentId >= 0
? parsedAgentId
: null;
const liveAgent = useLiveAgent(selectedAgentId, session?.persona_id ?? null);
// Project chats create with the default agent; new/chat honors the picked one.
const personaId = isProject
? DEFAULT_AGENT_ID
: (liveAgent?.id ?? selectedAgentId ?? DEFAULT_AGENT_ID);
const isDefaultAgent = personaId === DEFAULT_AGENT_ID;
const { messages, chatState, input, setInput, submit, stop, isHydrating } =
useChatController(sessionId, personaId, projectId);
// re-attaches to an in-flight run when opened cold
useChatSessionController(sessionId);
// The composer is one persistent instance across every focus; clear any unsent draft
// when the target conversation changes so it can't be sent into the wrong session.
useEffect(() => {
setInput("");
}, [sessionId, projectId, setInput]);
const { data: details, isLoading } = useProjectDetails(projectId);
const { agents } = useAgents();
const chats = details?.project?.chat_sessions ?? [];
const title = isProject
? details?.project?.name
: sessionId
? session?.name && session.name.trim()
? session.name
: UNNAMED_CHAT
: undefined;
const composer = (
<Animated.View layout={LinearTransition.duration(TRANSITION_MS)}>
<InputBar
value={input}
onChangeText={setInput}
onSend={() => {
void submit();
}}
onStop={stop}
chatState={chatState}
/>
</Animated.View>
);
const below = isProject ? (
<Animated.View
key="project-chats"
entering={FadeIn.duration(TRANSITION_MS)}
exiting={FadeOut.duration(TRANSITION_MS)}
className="flex-1"
>
<ScrollView keyboardShouldPersistTaps="handled">
<View className="px-24 pb-24 pt-8">
<ProjectChatSessionList
chats={chats}
agents={agents}
personaIdToFeatured={
details?.persona_id_to_is_featured ?? undefined
}
isLoading={isLoading && !details}
onSelect={(id) =>
router.navigate({ pathname: "/chat/[id]", params: { id } })
}
/>
</View>
</ScrollView>
</Animated.View>
) : undefined;
return (
<ChatScreen title={title} input={composer} below={below}>
{isProject ? (
<Animated.View
key="project-context"
entering={FadeIn.duration(TRANSITION_MS)}
exiting={FadeOut.duration(TRANSITION_MS)}
className="max-h-[50%]"
>
<ScrollView keyboardShouldPersistTaps="handled">
<View className="gap-24 px-24 pb-8 pt-8">
<ProjectContextPanel details={details} isLoading={isLoading} />
</View>
</ScrollView>
</Animated.View>
) : messages.length === 0 && !isHydrating ? (
<Animated.View
key="empty"
entering={FadeIn.duration(TRANSITION_MS)}
exiting={FadeOut.duration(TRANSITION_MS)}
className="flex-1"
>
<ChatEmptyState
agent={liveAgent}
isDefaultAgent={isDefaultAgent}
onStarterSelect={(message) => {
void submit(message);
}}
/>
</Animated.View>
) : (
<Animated.View
key="messages"
entering={FadeIn.duration(TRANSITION_MS)}
exiting={FadeOut.duration(TRANSITION_MS)}
className="flex-1"
>
<MessageList messages={messages} />
</Animated.View>
)}
</ChatScreen>
);
}
-116
View File
@@ -1,116 +0,0 @@
import { useState } from "react";
import { ScrollView, View } from "react-native";
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
} from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { router } from "expo-router";
import { ChatHeader } from "@/components/chat/ChatHeader";
import { InputBar } from "@/components/chat/InputBar";
import { MessageList } from "@/components/chat/MessageList";
import { ProjectContextPanel } from "@/components/chat/ProjectContextPanel";
import { ProjectChatSessionList } from "@/components/chat/ProjectChatSessionList";
import { useProjectDetails } from "@/api/chat/projects";
import { useAgents } from "@/api/chat/agents";
import { useChatController } from "@/hooks/useChatController";
interface ProjectViewProps {
projectId: number | null;
}
const TRANSITION_MS = 150;
// Project detail + chat (mirrors web's AppPage). The first send transitions in
// place — input slides down, panel/list fade — instead of navigating, so it's smooth.
export function ProjectView({ projectId }: ProjectViewProps) {
// set on first send; drives the project→chat swap
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const started = activeSessionId != null;
const { data: details, isLoading } = useProjectDetails(projectId);
const { agents } = useAgents();
const { messages, input, setInput, submit, stop, chatState } =
useChatController(
activeSessionId,
undefined,
projectId,
setActiveSessionId,
);
const chats = details?.project?.chat_sessions ?? [];
return (
<SafeAreaView edges={["top"]} className="flex-1 bg-background-neutral-00">
<ChatHeader title={details?.project?.name} />
<View className="flex-1">
{started ? (
<Animated.View
key="messages"
entering={FadeIn.duration(TRANSITION_MS)}
className="flex-1"
>
<MessageList messages={messages} />
</Animated.View>
) : (
<Animated.View
key="context"
exiting={FadeOut.duration(TRANSITION_MS)}
className="max-h-[50%]"
>
<ScrollView keyboardShouldPersistTaps="handled">
<View className="gap-24 px-24 pb-8 pt-8">
<ProjectContextPanel details={details} isLoading={isLoading} />
</View>
</ScrollView>
</Animated.View>
)}
<KeyboardStickyView>
<Animated.View layout={LinearTransition.duration(TRANSITION_MS)}>
<InputBar
value={input}
onChangeText={setInput}
onSend={() => {
void submit();
}}
onStop={stop}
chatState={chatState}
/>
</Animated.View>
</KeyboardStickyView>
{!started ? (
<Animated.View
key="chats"
exiting={FadeOut.duration(TRANSITION_MS)}
className="flex-1"
>
<ScrollView keyboardShouldPersistTaps="handled">
<View className="px-24 pb-24 pt-8">
<ProjectChatSessionList
chats={chats}
agents={agents}
personaIdToFeatured={
details?.persona_id_to_is_featured ?? undefined
}
isLoading={isLoading && !details}
onSelect={(sessionId) =>
router.navigate({
pathname: "/chat/[id]",
params: { id: sessionId },
})
}
/>
</View>
</ScrollView>
</Animated.View>
) : null}
</View>
</SafeAreaView>
);
}
@@ -0,0 +1,4 @@
// Chat routes render nothing — the persistent ChatSurface (in (app)/_layout) draws them.
export default function SurfaceRoute() {
return null;
}