From b23f8b3e74c2ff52046e321cbc82e8fd550590ce Mon Sep 17 00:00:00 2001 From: ronak-guliani Date: Mon, 16 Mar 2026 02:12:14 -0700 Subject: [PATCH 1/2] fix(connection): address code review feedback - complete refactoring, fix error handling, remove dead code Priority 1 (Blocking): - Document why app/_layout.tsx uses bootstrap() directly (app startup vs user-driven connection) - Restore comment in connect.tsx explaining connect-stage error handling via store side-effect - Remove dead requireRefreshSuccess flag and related logic (refresh is always best-effort) Priority 2 (Recommended): - Export BootstrapResultError and BootstrapResultSuccess discriminant types for better type reusability - Update tests to remove requireRefreshSuccess dead code test case All fixes maintain backward compatibility while improving code clarity and maintainability. --- packages/mobile/app/_layout.tsx | 4 ++++ packages/mobile/app/connect.tsx | 2 ++ packages/mobile/src/api/bootstrap.ts | 11 ++++++++-- .../connection/connect-and-bootstrap.test.ts | 20 ------------------- .../connection/connect-and-bootstrap.ts | 10 ++-------- 5 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/mobile/app/_layout.tsx b/packages/mobile/app/_layout.tsx index 143a09ebad06..6612b214c788 100644 --- a/packages/mobile/app/_layout.tsx +++ b/packages/mobile/app/_layout.tsx @@ -49,6 +49,10 @@ export default function RootLayout() { try { const [restored] = await Promise.all([restore(), restoreAppearance()]) if (restored) { + // Note: We call bootstrap() directly here (not connectAndBootstrap) because: + // - This is app startup, not a user-initiated connection + // - Connection already exists from restore() — we're just hydrating session state + // - connectAndBootstrap is for user-driven connect/server-switch flows await bootstrap() } } finally { diff --git a/packages/mobile/app/connect.tsx b/packages/mobile/app/connect.tsx index 8ea9fecf48c0..7391a2a9cffa 100644 --- a/packages/mobile/app/connect.tsx +++ b/packages/mobile/app/connect.tsx @@ -47,6 +47,8 @@ export default function ConnectScreen() { if (result.stage === "bootstrap") { setBootstrapError(result.error) } + // Note: connect-stage errors are already set in the store via useConnection.connect() + // The store's error state is rendered via the `error` selector above return } router.replace("/(main)/session") diff --git a/packages/mobile/src/api/bootstrap.ts b/packages/mobile/src/api/bootstrap.ts index 644c534db87e..338b101ca165 100644 --- a/packages/mobile/src/api/bootstrap.ts +++ b/packages/mobile/src/api/bootstrap.ts @@ -5,11 +5,18 @@ import { subscribe, unsubscribe } from "./events" export type BootstrapStatus = "loading" | "partial" | "complete" | "error" -export type BootstrapResult = { - status: BootstrapStatus +export type BootstrapResultError = { + status: "error" + error: string +} + +export type BootstrapResultSuccess = { + status: Exclude error?: string } +export type BootstrapResult = BootstrapResultSuccess | BootstrapResultError + export async function bootstrap(): Promise { try { // Phase 1 — blocking diff --git a/packages/mobile/src/features/connection/connect-and-bootstrap.test.ts b/packages/mobile/src/features/connection/connect-and-bootstrap.test.ts index 70afd8152b37..300dc0499fc2 100644 --- a/packages/mobile/src/features/connection/connect-and-bootstrap.test.ts +++ b/packages/mobile/src/features/connection/connect-and-bootstrap.test.ts @@ -104,24 +104,4 @@ describe("connectAndBootstrap", () => { expect(refreshCalls).toBeGreaterThan(0) expect(result).toEqual({ status: "success" }) }) - - test("returns refresh-stage error when refresh is required", async () => { - const result = await connectAndBootstrap( - { url: "https://example.test", refreshSessions: true, requireRefreshSuccess: true }, - { - connect: async () => undefined, - bootstrap: async () => ({ status: "complete" as const }), - fetchSessions: async () => { - throw new Error("session refresh failed") - }, - fetchStatuses: async () => undefined, - }, - ) - - expect(result).toEqual({ - status: "error", - stage: "refresh", - error: "session refresh failed", - }) - }) }) diff --git a/packages/mobile/src/features/connection/connect-and-bootstrap.ts b/packages/mobile/src/features/connection/connect-and-bootstrap.ts index bd200cf46b59..b228ce2fdbdb 100644 --- a/packages/mobile/src/features/connection/connect-and-bootstrap.ts +++ b/packages/mobile/src/features/connection/connect-and-bootstrap.ts @@ -19,7 +19,6 @@ type ConnectAndBootstrapOptions = { url: string auth?: ServerAuth refreshSessions?: boolean - requireRefreshSuccess?: boolean } export type ConnectAndBootstrapResult = @@ -103,13 +102,8 @@ export async function connectAndBootstrap( try { await Promise.all([deps.fetchSessions(), deps.fetchStatuses()]) } catch (error) { - if (options.requireRefreshSuccess) { - return { - status: "error", - stage: "refresh", - error: toErrorMessage(error, "Connected, but failed to refresh session data"), - } - } + // Log but don't fail — connection is successful, just session refresh had an issue + console.warn("Session refresh failed:", toErrorMessage(error)) } } From 6f4911da7a4caabab934cc109eeaf7809a6e34b3 Mon Sep 17 00:00:00 2001 From: ronak-guliani Date: Mon, 13 Apr 2026 22:08:42 -0700 Subject: [PATCH 2/2] Refactor mobile streaming and drawer architecture - Split sidebar and diff into separate drawers - Add telemetry and session diff panel plumbing - Prepare mobile chat rendering for smoother streaming --- packages/mobile/STREAMING_SPEC.md | 718 ++++++++++++++++++ packages/mobile/app/(main)/_layout.tsx | 212 ++++-- .../mobile/app/(main)/session/[id]/diff.tsx | 207 +++-- .../mobile/app/(main)/session/[id]/index.tsx | 122 +-- packages/mobile/src/api/events.ts | 34 +- .../src/components/chat/assistant-message.tsx | 44 +- .../mobile/src/components/chat/composer.tsx | 17 +- packages/mobile/src/components/chat/list.tsx | 260 ++++--- packages/mobile/src/components/chat/part.tsx | 630 ++++++++++++--- .../mobile/src/components/chat/provider.tsx | 41 +- .../components/chat/turn-diff-indicator.tsx | 98 +++ .../src/components/chat/user-message.tsx | 16 +- .../src/components/markdown/code-block.tsx | 31 +- .../src/components/markdown/renderer.tsx | 84 +- .../src/components/session-diff-panel.tsx | 667 ++++++++++++++++ .../mobile/src/components/sidebar/index.tsx | 380 +++++---- packages/mobile/src/perf/telemetry.ts | 106 +++ packages/mobile/src/store/diffs.ts | 4 + packages/mobile/src/store/messages.ts | 79 +- packages/mobile/src/store/sessions.ts | 13 +- 20 files changed, 3131 insertions(+), 632 deletions(-) create mode 100644 packages/mobile/STREAMING_SPEC.md create mode 100644 packages/mobile/src/components/chat/turn-diff-indicator.tsx create mode 100644 packages/mobile/src/components/session-diff-panel.tsx create mode 100644 packages/mobile/src/perf/telemetry.ts diff --git a/packages/mobile/STREAMING_SPEC.md b/packages/mobile/STREAMING_SPEC.md new file mode 100644 index 000000000000..a3928d599db0 --- /dev/null +++ b/packages/mobile/STREAMING_SPEC.md @@ -0,0 +1,718 @@ +# Smooth Streaming Spec + +Build-ready proposal for smoother assistant response streaming in the mobile app. + +Updated: 2026-03-24 + +--- + +## 1. Goal + +Make assistant responses feel continuous and calm while streaming, without changing the transport layer or final canonical message state. + +### Goals + +- Reduce visible jank during active assistant responses. +- Reduce render churn caused by streaming deltas. +- Preserve correctness of final completed content. +- Keep existing tool, file, diff, hydration, and retry behavior intact. +- Keep the current SSE transport and batching model. + +### Non-goals + +- No server API or SSE protocol changes. +- No redesign of `@opencode-ai/sdk/client` message schemas. +- No persistence of ephemeral in-flight stream state. +- No redesign of tool/file/patch/diff rendering beyond what is necessary for smooth text streaming. + +--- + +## 2. Current Problems + +The app is already fast, but the streamed output still feels uneven. The issue appears to be rendering cadence and ownership, not network throughput. + +### Current implementation + +- SSE events are batched and coalesced in `src/api/events.ts`. +- Canonical message and part state lives in `src/store/messages.ts`. +- Chat history renders through `FlashList` in `src/components/chat/list.tsx`. +- Assistant rows render parts in `src/components/chat/assistant-message.tsx` and `src/components/chat/part.tsx`. +- Markdown rendering lives in `src/components/markdown/renderer.tsx`. + +### Main issues + +1. `message.part.delta` still drives frequent canonical state updates. +2. Active assistant text goes through the full markdown path while it is still changing. +3. `MarkdownRenderer` adds its own typed reveal via `useTypedStreamingText`, creating double smoothing on top of already-batched SSE. +4. Assistant rows recompute grouped parts as active part arrays change. +5. Chat bottom-follow is shared between `maintainVisibleContentPosition` and manual `scrollToEnd`, which creates competing scroll ownership. +6. Complex markdown is expensive to keep reparsing during active streaming. + +### Symptoms + +- Text appears in uneven bursts instead of a stable cadence. +- Scroll position can feel slightly jumpy near the bottom. +- Complex markdown can lag, then catch up abruptly. +- The UI pays full markdown costs before content is stable enough to benefit from it. + +--- + +## 3. Proposed Architecture + +Keep the current transport batching and canonical store, but introduce a dedicated ephemeral live-stream layer for in-flight assistant text. + +### Core idea + +Split streamed assistant text into two versions: + +- `rawText`: authoritative in-flight text as it arrives from SSE. +- `visibleText`: UI snapshot shown to the user at a controlled cadence. + +### Ownership model + +- `src/api/events.ts`: transport batching, parsing, and event routing. +- `src/store/live-stream.ts`: ephemeral in-flight text and cadence-based UI flushes. +- `src/store/messages.ts`: canonical final state, hydration, caching, persistence, and resync. +- `src/components/chat/list.tsx`: only owner of bottom-follow behavior. +- `src/components/markdown/renderer.tsx`: final markdown renderer, not a stream scheduler. + +### Rendering model + +- Active assistant `text` and `reasoning` render as lightweight plain text. +- Completed assistant `text` and `reasoning` render through full markdown. +- Tool/file/patch/diff rendering stays unchanged in phase 1. + +### Why this should feel smoother + +- The UI stops reparsing markdown for every small delta. +- Only the active assistant text updates frequently. +- The app avoids double buffering from both SSE batching and renderer-level typing. +- Hidden messages stop paying per-frame work. + +--- + +## 4. State Model and Data Flow + +### New store + +Add `src/store/live-stream.ts`. + +Suggested types: + +```ts +type LivePartKind = "text" | "reasoning" +type LivePartKey = `${string}:${string}` + +type LivePartState = { + sessionID: string + messageID: string + partID: string + kind: LivePartKind + baseText: string + rawText: string + visibleText: string + isActive: boolean + isVisible: boolean + updatedAt: number + lastFlushAt: number +} + +type LiveCommit = { + messageID: string + partID: string + field: "text" + value: string +} + +type LiveStreamState = { + byPart: Record + bySession: Record + visibleMessageIDs: Record + + seedPart: (part: Part) => void + appendDelta: (event: { sessionID: string; messageID: string; partID: string; field: string; delta: string }) => void + setVisibleMessages: (sessionID: string, messageIDs: string[]) => void + flushVisible: (sessionID: string) => void + commitMessage: (sessionID: string, messageID: string) => LiveCommit[] + commitSession: (sessionID: string) => LiveCommit[] + clearMessage: (sessionID: string, messageID: string) => void + clearSession: (sessionID: string) => void +} +``` + +### Routing rules + +Use the existing event stream, but split routing based on event type and field. + +#### Keep canonical routing for + +- `message.updated` +- `message.removed` +- `message.part.updated` +- `message.part.removed` +- non-text deltas + +#### Route to live stream store for + +- `message.part.delta` where: + - `field === "text"` + - part type is `text` or `reasoning` + - new feature flag is enabled + +### Lifecycle + +1. SSE chunk arrives. +2. `src/api/events.ts` continues batching/coalescing. +3. `message.part.updated` seeds live state for eligible parts. +4. `message.part.delta` appends into `rawText` only. +5. A cadence-based flush copies `rawText` into `visibleText` for visible messages. +6. UI renders active text from `visibleText`. +7. On completion or idle, live text commits into `src/store/messages.ts` and live state clears. + +### Canonical commit API + +Add to `src/store/messages.ts`: + +```ts +type StreamCommit = { + messageID: string + partID: string + field: "text" + value: string +} + +_commitStreamParts: (sessionID: string, commits: StreamCommit[]) => void +``` + +### Merged selector API + +Add to `src/api/hooks.ts`: + +```ts +type LiveOverlay = { + mode: "canonical" | "streaming" + text: string + isActive: boolean +} + +type RenderablePart = { + part: Part + live: LiveOverlay | null +} + +export function useRenderableMessageParts(messageID: string): RenderablePart[] +``` + +This hook should merge canonical parts with any live overlays for rendering. + +--- + +## 5. File-by-File Implementation Plan + +### `src/api/events.ts` + +- Keep SSE queueing, adaptive flush, stall watchdog, and event coalescing. +- Keep batching behavior unchanged. +- Split canonical events from live text events. + +Implementation changes: + +- On `message.part.updated`: + - keep existing `messages._applyEvents([event])` + - if part type is `text` or `reasoning`, also call `liveStream.seedPart(part)` +- On eligible `message.part.delta`: + - call `liveStream.appendDelta(event.properties)` + - do not forward that delta into `messages._applyEvents` +- On ineligible deltas: + - keep current canonical path +- On `message.updated` with `info.time.completed`: + - call `liveStream.commitMessage(sessionID, messageID)` + - pass returned commits into `messages._commitStreamParts(sessionID, commits)` + - clear live state for that message +- On `session.idle`: + - commit live session state first + - then keep existing forced diff/message refresh behavior +- On `message.removed` and `message.part.removed`: + - clear matching live entries + +### `src/store/messages.ts` + +- Keep this store canonical. +- Do not use it for cadence-based live reveal. + +Implementation changes: + +- Add `_commitStreamParts(sessionID, commits)` +- Update only targeted text/reasoning parts +- Increment `partsVersionBySession` +- Update `loadedAt` +- Schedule persistence once per commit batch +- Keep current delta buffering logic for non-text fields and late deltas + +Conflict resolution rules: + +1. If canonical text equals committed text, clear live only. +2. If committed text starts with canonical text, write committed text. +3. If canonical text starts with committed text, keep canonical text. +4. Otherwise prefer the longer value, log telemetry, and clear live state. + +### `src/store/live-stream.ts` (new) + +- Create a dedicated Zustand store with no persistence. +- Track `rawText` and `visibleText` separately. +- Use one session-level flush scheduler, not one timer per part. + +Required behavior: + +- `seedPart(part)` initializes or refreshes a live entry. +- `appendDelta(event)` appends only to `rawText`. +- `setVisibleMessages(sessionID, messageIDs)` marks which streamed messages should flush. +- `flushVisible(sessionID)` copies `rawText` to `visibleText` for visible active messages. +- `commitMessage` and `commitSession` return canonical commit payloads. +- `clearMessage` and `clearSession` remove ephemeral state. + +### `src/api/hooks.ts` + +- Add `useRenderableMessageParts(messageID)`. +- Merge canonical parts with live overlays. +- Keep existing simple selectors intact. +- Prefer a single merged hook over many small hot-path subscriptions. + +### `src/components/chat/assistant-message.tsx` + +- Replace `useMessageParts(message.id)` with `useRenderableMessageParts(message.id)`. +- Compute grouping from canonical part structure. +- Pass `liveText` and `renderMode` into `PartRenderer`. +- Keep retry, copy, footer, and token display unchanged. + +### `src/components/chat/part.tsx` + +Extend props: + +```ts +type Props = { + part: Part + isUser: boolean + isActive?: boolean + isStreamingComplete?: boolean + liveText?: string | null + renderMode?: "canonical" | "streaming" + onHydrateMessage?: (messageID: string) => void +} +``` + +Implementation changes: + +- Assistant `text` and `reasoning`: + - if `renderMode === "streaming"`, render a lightweight text component + - otherwise render `MarkdownRenderer` +- Keep tool, file, patch, snapshot, retry, and other part types unchanged in phase 1 + +### `src/components/markdown/renderer.tsx` + +- Remove `useTypedStreamingText`. +- Keep `StreamdownRN` and legacy fallback for completed markdown. +- Add a lightweight active renderer: + +```ts +export const StreamingTextRenderer = memo(function StreamingTextRenderer({ + text, + variant = "default", +}: { + text: string + variant?: "default" | "reasoning" +}) { + // plain Text, preserve line breaks, no markdown parse +}) +``` + +Phase 1 renderer policy: + +- active content: plain text only +- completed content: full markdown + +### `src/components/chat/list.tsx` + +- Add `onViewableItemsChanged`. +- Publish visible message IDs into the live stream store. +- Make this file the only owner of bottom-follow when the new flag is enabled. + +Implementation changes: + +- Add a local bottom-follow state machine: + - `follow` + - `detached` +- When new scroll mode is enabled, disable `maintainVisibleContentPosition` +- Keep manual prepend behavior for older messages +- Ensure only one path calls `scrollToEnd` + +### `src/config/feature-flags.ts` + +Add flags: + +```ts +liveStreamText: false, +liveStreamVisibleOnlyFlush: true, +streamingPlainTextRenderer: true, +singleOwnerChatScroll: true, +``` + +### `src/perf/chat-metrics.ts` + +Add metrics for: + +- live flush cadence +- live commit count +- mismatch/conflict count +- auto-follow scroll events +- auto-follow detach events + +--- + +## 6. Detailed Behavior Rules + +### 6.1 Flush cadence + +Keep transport flush unchanged in `src/api/events.ts`. + +Add a second UI flush layer in `src/store/live-stream.ts`. + +#### Default cadence + +- iOS: `32ms` +- Android: `40ms` + +#### Scheduling + +- Use `requestAnimationFrame` when available. +- Keep a timeout fallback at the same cadence. +- Use one scheduler per active session. + +#### Flush eligibility + +Only flush a session when: + +- the session has active live parts +- at least one relevant message is visible + +#### Flush behavior + +- Copy `rawText -> visibleText` only for eligible live parts. +- Batch all changes into one store update. +- Skip unchanged parts. +- If lag exceeds `600` chars, fast-forward on the next flush. +- If a hidden message becomes visible, snap `visibleText` to `rawText` on the next flush. + +### 6.2 Autoscroll + +When `singleOwnerChatScroll` is enabled, `src/components/chat/list.tsx` is the only file allowed to call `scrollToEnd` for streaming follow behavior. + +#### States + +- `follow`: user is at or near the bottom +- `detached`: user has intentionally moved away from bottom + +#### Enter `follow` + +- initial load completes +- user taps jump-to-bottom +- user scrolls back within `120px` of bottom +- user sends a message while already near bottom + +#### Enter `detached` + +- drag begins and distance from end exceeds `160px` +- jump button becomes visible + +#### Rules while following + +- auto-scroll no more than once every `48ms` +- use `scrollToEnd({ animated: false })` +- never auto-scroll while `loadingOlder` is true + +#### Migration rule + +- disable `maintainVisibleContentPosition` when new scroll mode is enabled +- do not keep mixed ownership + +### 6.3 Markdown policy + +#### Active assistant text + +- render with `StreamingTextRenderer` +- preserve line breaks +- allow selection +- do not parse markdown + +#### Completed assistant text + +- render with `MarkdownRenderer` +- allow existing code blocks, links, tables, lists, and styling to appear after completion + +#### Phase 1 intentionally does not support during active streaming + +- fenced code highlighting +- tables +- inline markdown formatting +- tappable markdown links + +This is a deliberate tradeoff for smoother live output. + +### 6.4 Commit policy + +Commit live text into canonical state on: + +- `message.updated` with `time.completed` +- `session.idle` +- forced resync +- unsubscribe/reset + +Commit order: + +1. Build commits from live store. +2. Apply `_commitStreamParts(sessionID, commits)`. +3. Clear live entries. +4. Continue any existing forced reload/resync logic. + +Never persist `visibleText`. + +--- + +## 7. Feature Flags + +Use small, composable flags for safe rollout. + +| Flag | Default | Purpose | +| ---------------------------- | ------- | ---------------------------------------- | +| `liveStreamText` | `false` | Master switch for ephemeral live text | +| `liveStreamVisibleOnlyFlush` | `true` | Flush only visible streamed messages | +| `streamingPlainTextRenderer` | `true` | Use lightweight renderer for active text | +| `singleOwnerChatScroll` | `true` | Remove competing bottom-follow behavior | + +Recommended enable order: + +1. `streamingPlainTextRenderer` +2. `singleOwnerChatScroll` +3. `liveStreamText` +4. `liveStreamVisibleOnlyFlush` + +--- + +## 8. Metrics and Observability + +Add telemetry and perf markers for both transport and UI reveal. + +### Counters + +- `chat:liveFlush` +- `chat:liveCommit` +- `chat:liveMismatch` +- `chat:autoFollowScroll` +- `chat:autoFollowDetached` + +### Dimensions + +- `sessionID` +- `messageID` +- `partID` +- `platform` +- `visibleParts` +- `flushedChars` +- `rawLagChars` +- `flushMs` +- `commits` +- `reason` + +### Derived metrics + +- time from first delta to first visible text +- average chars flushed per UI tick +- P95 UI flush duration +- P95 commit duration +- mismatch rate +- auto-follow detach rate during active responses + +### Success thresholds + +- P95 UI flush under `8ms` +- no full markdown parse during active assistant text +- no visible double-typing effect +- no visible text backtracking +- final committed text matches server output + +--- + +## 9. Testing Plan + +### Unit tests + +#### `src/store/live-stream.ts` + +- append delta to seeded part +- ignore unsupported fields +- flush only visible messages +- fast-forward when lag is too large +- commit single message +- commit whole session +- clear on remove and reset + +#### `src/store/messages.ts` + +- `_commitStreamParts` updates only targeted parts +- conflict resolution order is correct +- persistence is scheduled once per commit batch + +### Integration tests + +#### `src/api/events.ts` + +- eligible text delta routes to live store +- non-text delta stays canonical +- completion triggers commit +- idle triggers session commit before reload + +#### `src/components/chat/assistant-message.tsx` and `src/components/chat/part.tsx` + +- active assistant text uses lightweight renderer +- completed assistant text upgrades to markdown +- reasoning follows the same rule +- tool/file/patch rendering remains unchanged + +#### `src/components/chat/list.tsx` + +- only one scroll owner is active +- follow/detached transitions are correct +- jump button appears only when detached +- loading older messages never forces bottom scroll + +### Manual QA + +- long prose response +- code-heavy markdown response +- reasoning-heavy response +- background the app during an active response, then return +- navigate away from a busy session, then return +- slow network with bursty SSE chunks +- very long session with many historical messages + +--- + +## 10. Rollout Plan + +### Phase 0: Instrumentation + +- land all new paths behind flags +- ship metrics first +- keep behavior unchanged by default + +### Phase 1: Renderer and scroll isolation + +- enable `streamingPlainTextRenderer` internally +- enable `singleOwnerChatScroll` internally +- keep `liveStreamText` off + +Goal: isolate renderer and scroll effects before changing data flow. + +### Phase 2: Live stream store on iOS + +- enable `liveStreamText` for internal iOS builds +- verify flush cost, mismatch rate, and user perception + +### Phase 3: Live stream store on Android + +- enable for internal Android builds +- compare performance and correctness against iOS + +### Phase 4: Visible-only flush and cleanup + +- enable `liveStreamVisibleOnlyFlush` +- remove old typed-streaming logic after one stable release cycle + +--- + +## 11. Acceptance Criteria + +Implementation is done when all are true: + +- transport batching in `src/api/events.ts` is preserved +- canonical final content still lives in `src/store/messages.ts` +- active assistant `text` and `reasoning` no longer use `useTypedStreamingText` +- active assistant text does not run full markdown parsing on every delta +- visible assistant text updates on a stable UI cadence from ephemeral state +- hidden messages do not cause unnecessary per-frame work +- final completion commits back into canonical state exactly once +- existing retry, copy, load-more, hydration, tool, and diff behavior still works +- only one scroll policy owns bottom follow +- jump-to-bottom behavior remains predictable +- telemetry confirms lower active render work without correctness regressions + +--- + +## 12. Risks and Fallback Plan + +### Risks + +- switching from plain active text to final markdown can cause one-time layout reflow +- active responses may look temporarily less rich because markdown is deferred +- reconnects or mixed snapshot/delta ordering can create commit mismatches +- scroll behavior can still regress if old paths remain partially enabled +- subscribing broadly to both canonical and live state can erase the intended performance win + +### Fallback + +- disable `liveStreamText` to return to canonical text-delta updates +- disable `streamingPlainTextRenderer` to restore current markdown behavior +- disable `singleOwnerChatScroll` to restore current scroll behavior +- keep code paths isolated so each flag can be reverted independently + +--- + +## 13. Implementation Checklist + +### Foundation + +- [ ] Add `src/store/live-stream.ts` +- [ ] Add new feature flags in `src/config/feature-flags.ts` +- [ ] Add new metrics in `src/perf/chat-metrics.ts` + +### Event routing + +- [ ] Seed live parts from `message.part.updated` +- [ ] Route eligible `message.part.delta` events into live store +- [ ] Commit live text on message completion +- [ ] Commit live text on `session.idle` +- [ ] Clear live entries on message/part removal + +### Canonical state + +- [ ] Add `_commitStreamParts(sessionID, commits)` to `src/store/messages.ts` +- [ ] Preserve existing non-text delta behavior +- [ ] Schedule one persistence write per commit batch + +### Rendering + +- [ ] Add `useRenderableMessageParts(messageID)` in `src/api/hooks.ts` +- [ ] Update `src/components/chat/assistant-message.tsx` to use merged renderable parts +- [ ] Extend `PartRenderer` to support `liveText` and `renderMode` +- [ ] Add `StreamingTextRenderer` in `src/components/markdown/renderer.tsx` +- [ ] Remove `useTypedStreamingText` + +### Scroll ownership + +- [ ] Add viewability tracking in `src/components/chat/list.tsx` +- [ ] Publish visible message IDs to live stream store +- [ ] Add local follow/detached state machine +- [ ] Disable `maintainVisibleContentPosition` behind new scroll flag +- [ ] Ensure only one path calls `scrollToEnd` + +### Validation + +- [ ] Add unit tests for live stream store and stream commits +- [ ] Add integration tests for event routing and render mode transitions +- [ ] Run manual QA on iOS and Android + +--- + +## 14. Recommendation + +Implement this in phases, starting with renderer and scroll ownership cleanup before moving text deltas into the ephemeral live stream store. That sequence gives the best chance of improving perceived smoothness quickly while keeping rollback simple. diff --git a/packages/mobile/app/(main)/_layout.tsx b/packages/mobile/app/(main)/_layout.tsx index 065d6f832cc9..390a5b7c06b6 100644 --- a/packages/mobile/app/(main)/_layout.tsx +++ b/packages/mobile/app/(main)/_layout.tsx @@ -1,16 +1,18 @@ -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useContext, useEffect, useMemo, useState } from "react" import { View, StyleSheet, useWindowDimensions, Platform, type ViewProps } from "react-native" import { Stack, useRouter } from "expo-router" -import { Drawer, useDrawerProgress } from "react-native-drawer-layout" +import { Drawer, DrawerGestureContext, useDrawerProgress } from "react-native-drawer-layout" import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated" import type { Session } from "@opencode-ai/sdk/client" import { Sidebar } from "../../src/components/sidebar" +import { SessionDiffPanel } from "../../src/components/session-diff-panel" import { ConnectionBanner } from "../../src/components/connection-banner" import { useTheme } from "../../src/theme" import { useSessions } from "../../src/store/sessions" import { useConnection } from "../../src/store/connection" import { useSidebar } from "../../src/store/sidebar" import { addCrashBreadcrumb } from "../../src/perf/crash-breadcrumbs" +import { telemetry } from "../../src/perf/telemetry" const AnimatedView = Animated.View as React.ComponentType const SWIPE_SURFACE_OVERLAY_OPACITY = 0.14 @@ -22,6 +24,7 @@ export default function MainLayout() { const { width } = useWindowDimensions() const isTablet = width >= 768 const [open, setOpen] = useState(isTablet) + const [diffOpen, setDiffOpen] = useState(false) const currentSessionID = useSessions((s) => s.current) const select = useSessions((s) => s.select) const directory = useConnection((s) => s.directory) @@ -32,6 +35,11 @@ export default function MainLayout() { setOpen(isTablet) }, [isTablet]) + // Close diff panel when session changes + useEffect(() => { + setDiffOpen(false) + }, [currentSessionID]) + const setOpenIfChanged = useCallback((next: boolean) => { setOpen((current) => (current === next ? current : next)) }, []) @@ -50,6 +58,7 @@ export default function MainLayout() { return } if (!isTablet) setOpenIfChanged(false) + telemetry.track("session", "session:switch", { sessionID: session.id }) addCrashBreadcrumb("session-switch:start", { sessionID: session.id, toDirectory: session.directory, @@ -61,14 +70,18 @@ export default function MainLayout() { [currentSessionID, directory, isTablet, router, setOpenIfChanged], ) - const onNewSession = useCallback(async (worktree?: string) => { - if (worktree && worktree !== directory) { - await switchDirectory(worktree) - } - select(null) - if (!isTablet) setOpenIfChanged(false) - router.replace("/(main)/session") - }, [directory, isTablet, router, select, setOpenIfChanged, switchDirectory]) + const onNewSession = useCallback( + async (worktree?: string) => { + telemetry.track("session", "session:new", { worktree: worktree ?? null }) + if (worktree && worktree !== directory) { + await switchDirectory(worktree) + } + select(null) + if (!isTablet) setOpenIfChanged(false) + router.replace("/(main)/session") + }, + [directory, isTablet, router, select, setOpenIfChanged, switchDirectory], + ) const onSettings = useCallback(() => { if (!isTablet) setOpenIfChanged(false) @@ -80,7 +93,7 @@ export default function MainLayout() { setOpenIfChanged(false) }, [isTablet, setOpenIfChanged]) - const drawerStyle = useMemo( + const sidebarDrawerStyle = useMemo( () => ({ width: isTablet ? 320 : width, backgroundColor: "transparent", @@ -88,7 +101,15 @@ export default function MainLayout() { [isTablet, width], ) - const renderDrawerContent = useCallback( + const diffDrawerStyle = useMemo( + () => ({ + width: isTablet ? width - 320 : width, + backgroundColor: "transparent", + }), + [isTablet, width], + ) + + const renderSidebarContent = useCallback( () => ( { - if (!isTablet) setOpenIfChanged(true) + if (!isTablet) { + telemetry.track("drawer", "drawer:left:open") + setOpenIfChanged(true) + } }} onClose={() => { - if (!isTablet) setOpenIfChanged(false) + if (!isTablet) { + telemetry.track("drawer", "drawer:left:close") + setOpenIfChanged(false) + } }} drawerType={isTablet ? "permanent" : "slide"} - swipeEnabled={!isTablet} + swipeEnabled={!isTablet && !diffOpen} swipeEdgeWidth={isTablet ? 0 : width} swipeMinDistance={DRAWER_SWIPE_MIN_DISTANCE} swipeMinVelocity={DRAWER_SWIPE_MIN_VELOCITY} overlayStyle={styles.drawerOverlay} - drawerStyle={drawerStyle} - renderDrawerContent={renderDrawerContent} + drawerStyle={sidebarDrawerStyle} + renderDrawerContent={renderSidebarContent} > - + + + ) } -function SlidingContent({ isTablet }: { isTablet: boolean }) { +function RightDrawer({ + isTablet, + diffOpen, + setDiffOpen, + currentSessionID, + sidebarOpen, + diffDrawerStyle, +}: { + isTablet: boolean + diffOpen: boolean + setDiffOpen: (open: boolean) => void + currentSessionID: string | null + sidebarOpen: boolean + diffDrawerStyle: { width: number; backgroundColor: string } +}) { + const { width } = useWindowDimensions() + const parentGesture = useContext(DrawerGestureContext as React.Context) + + const renderDiffContent = useCallback( + () => + currentSessionID ? ( + + ) : ( + + ), + [currentSessionID], + ) + + // Disable right drawer swipe when left sidebar is open (gesture conflict) + const swipeEnabled = !!currentSessionID && !sidebarOpen && !isTablet + + // Let both drawer gestures run simultaneously so each direction works independently + const configureGesture = useCallback( + (gesture: Parameters["configureGestureHandler"]>>[0]) => { + if (parentGesture) return gesture.simultaneousWithExternalGesture(parentGesture as never) + return gesture + }, + [parentGesture], + ) + + return ( + { + telemetry.track("drawer", "drawer:right:open") + setDiffOpen(true) + }} + onClose={() => { + telemetry.track("drawer", "drawer:right:close") + setDiffOpen(false) + }} + drawerType="slide" + swipeEnabled={swipeEnabled} + swipeEdgeWidth={width} + swipeMinDistance={DRAWER_SWIPE_MIN_DISTANCE} + swipeMinVelocity={DRAWER_SWIPE_MIN_VELOCITY} + configureGestureHandler={configureGesture} + overlayStyle={styles.drawerOverlay} + drawerStyle={diffDrawerStyle} + renderDrawerContent={renderDiffContent} + > + + + + + + + + + ) +} + +function LeftOverlay({ isTablet, children }: { isTablet: boolean; children: React.ReactNode }) { const theme = useTheme() const progress = useDrawerProgress() - const blurOverlayStyle = useAnimatedStyle( + const overlayStyle = useAnimatedStyle( () => ({ opacity: isTablet ? 0 : interpolate(progress.value, [0, 1], [0, SWIPE_SURFACE_OVERLAY_OPACITY]), }), @@ -137,28 +257,34 @@ function SlidingContent({ isTablet }: { isTablet: boolean }) { return ( - - - - - + {children} + {!isTablet && Platform.OS === "ios" ? ( + + + + ) : null} + + ) +} + +function RightOverlay({ isTablet, children }: { isTablet: boolean; children: React.ReactNode }) { + const theme = useTheme() + const progress = useDrawerProgress() + + const overlayStyle = useAnimatedStyle( + () => ({ + opacity: isTablet ? 0 : interpolate(progress.value, [0, 1], [0, SWIPE_SURFACE_OVERLAY_OPACITY]), + }), + [isTablet], + ) + + return ( + + {children} {!isTablet && Platform.OS === "ios" ? ( - <> - - - - + + + ) : null} ) @@ -168,10 +294,10 @@ const styles = StyleSheet.create({ content: { flex: 1, }, - mainBlurOverlay: { + blurOverlay: { ...StyleSheet.absoluteFillObject, }, - mainBlurTint: { + blurTint: { ...StyleSheet.absoluteFillObject, }, drawerOverlay: { diff --git a/packages/mobile/app/(main)/session/[id]/diff.tsx b/packages/mobile/app/(main)/session/[id]/diff.tsx index 3c197226561a..d3567ed99396 100644 --- a/packages/mobile/app/(main)/session/[id]/diff.tsx +++ b/packages/mobile/app/(main)/session/[id]/diff.tsx @@ -1,13 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { - FlatList, - Platform, - Pressable, - StyleSheet, - Text, - TextInput, - View, -} from "react-native" +import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react" +import { Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native" +import { FlashList } from "@shopify/flash-list" import { useLocalSearchParams, useRouter } from "expo-router" import { useSafeAreaInsets } from "react-native-safe-area-context" import Feather from "@expo/vector-icons/Feather" @@ -21,6 +14,7 @@ import { resolveDiffLineCounts } from "../../../../src/features/diff/counts" import { synthesizeSessionDiff } from "../../../../src/features/diff/synthetic" import type { DiffLine, SessionFileDiff } from "../../../../src/features/diff/types" import { useSessionPartsMap } from "../../../../src/api/hooks" +import { resolveTurnDiffs } from "../../../../src/components/chat/diff-ui-logic" const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string }> @@ -43,15 +37,7 @@ function asNumber(value: unknown) { } function resolveFilePath(diff: Record) { - const candidates = [ - diff.file, - diff.path, - diff.filePath, - diff.filepath, - diff.relativePath, - diff.filename, - diff.name, - ] + const candidates = [diff.file, diff.path, diff.filePath, diff.filepath, diff.relativePath, diff.filename, diff.name] for (const candidate of candidates) { const value = asString(candidate).trim() if (value) return value @@ -142,11 +128,11 @@ export default function SessionDiffScreen() { const parsedRowsCacheRef = useRef>(new Map()) const session = useSessions((s) => (id ? s.sessions.find((item) => item.id === id) : undefined)) - const sessionMessages = useMessages((s) => (id ? s.messages[id] ?? [] : [])) + const sessionMessages = useMessages((s) => (id ? (s.messages[id] ?? []) : [])) const partsByMessage = useSessionPartsMap(id) - const diffs = useDiffs((s) => (id ? s.bySession[id] ?? EMPTY_DIFFS : EMPTY_DIFFS)) - const loading = useDiffs((s) => (id ? s.loading[id] ?? false : false)) - const error = useDiffs((s) => (id ? s.error[id] ?? null : null)) + const diffs = useDiffs((s) => (id ? (s.bySession[id] ?? EMPTY_DIFFS) : EMPTY_DIFFS)) + const loading = useDiffs((s) => (id ? (s.loading[id] ?? false) : false)) + const error = useDiffs((s) => (id ? (s.error[id] ?? null) : null)) const fetchSessionDiff = useDiffs((s) => s.fetchSessionDiff) useEffect(() => { @@ -178,7 +164,7 @@ export default function SessionDiffScreen() { return result }, [sessionMessages]) - const targetTurnMessageID = mode === "turn" ? (turnMessageID || latestUserMessageID) : "" + const targetTurnMessageID = mode === "turn" ? turnMessageID || latestUserMessageID : "" const turnDiffs = useMemo(() => { if (mode !== "turn" || !targetTurnMessageID) return EMPTY_DIFFS @@ -194,9 +180,7 @@ export default function SessionDiffScreen() { }, [mode, sessionMessages, targetTurnMessageID]) const apiDiffs = useMemo(() => { - const normalized = diffs - .map((item) => normalizeDiffEntry(item)) - .filter((item): item is SessionFileDiff => !!item) + const normalized = diffs.map((item) => normalizeDiffEntry(item)).filter((item): item is SessionFileDiff => !!item) return dedupeDiffs(normalized) }, [diffs]) @@ -241,8 +225,14 @@ export default function SessionDiffScreen() { } }, [mode, partsByMessage, sessionMessages]) - const sessionDiffsMerged = useMemo(() => dedupeDiffs([...apiDiffs, ...syntheticSessionDiffs]), [apiDiffs, syntheticSessionDiffs]) - const turnDiffsMerged = useMemo(() => dedupeDiffs([...normalizedTurnDiffs, ...syntheticTurnDiffs]), [normalizedTurnDiffs, syntheticTurnDiffs]) + const sessionDiffsMerged = useMemo( + () => dedupeDiffs([...apiDiffs, ...syntheticSessionDiffs]), + [apiDiffs, syntheticSessionDiffs], + ) + const turnDiffsMerged = useMemo( + () => dedupeDiffs(resolveTurnDiffs(normalizedTurnDiffs, syntheticTurnDiffs)), + [normalizedTurnDiffs, syntheticTurnDiffs], + ) const safeDiffs = mode === "turn" ? turnDiffsMerged : sessionDiffsMerged const summaryFromDiffs = useMemo(() => computeSummary(safeDiffs), [safeDiffs]) @@ -329,17 +319,33 @@ export default function SessionDiffScreen() { {item.file} - + +{item.additions} -{item.deletions} - + {isExpanded ? ( {visibleRows.map((line, index) => ( - + ))} {remaining > 0 ? ( ) }, - [expanded, getParsedRows, lineCap, loadMore, theme.colors.border, theme.colors.error, theme.colors.success, theme.colors.surface, theme.colors.text, theme.colors.textTertiary, theme.colors.borderSubtle, theme.colors.surfaceRaised, theme.colors.textSecondary, toggleFile], + [ + expanded, + getParsedRows, + lineCap, + loadMore, + theme.colors.border, + theme.colors.error, + theme.colors.success, + theme.colors.surface, + theme.colors.text, + theme.colors.textTertiary, + theme.colors.borderSubtle, + theme.colors.surfaceRaised, + theme.colors.textSecondary, + toggleFile, + ], ) if (!id) return null @@ -388,19 +409,44 @@ export default function SessionDiffScreen() { - - - + + + - item.file} + keyExtractor={diffKeyExtractor} ListHeaderComponent={ - + - {mode === "session" && error ? {error} : null} + {mode === "session" && error ? ( + {error} + ) : null} {mode === "session" && error ? ( [styles.retry, pressed && styles.pressed, { borderColor: theme.colors.border }]} + style={({ pressed }) => [ + styles.retry, + pressed && styles.pressed, + { borderColor: theme.colors.border }, + ]} onPress={onRefresh} > Retry @@ -444,36 +496,63 @@ export default function SessionDiffScreen() { ) } -function StatusBadge({ status }: { status: string }) { - const theme = useTheme() - const color = status === "added" ? theme.colors.success : status === "deleted" ? theme.colors.error : theme.colors.textTertiary +const StatusBadge = memo(function StatusBadge({ + status, + successColor, + errorColor, + tertiaryColor, +}: { + status: string + successColor: string + errorColor: string + tertiaryColor: string +}) { + const color = status === "added" ? successColor : status === "deleted" ? errorColor : tertiaryColor return ( {status} ) -} +}) -function SummaryChip({ +const SummaryChip = memo(function SummaryChip({ label, value, valueColor, + borderColor, + backgroundColor, + textColor, + tertiaryColor, }: { label: string value: string valueColor?: string + borderColor: string + backgroundColor: string + textColor: string + tertiaryColor: string }) { - const theme = useTheme() return ( - - {value} - {label} + + {value} + {label} ) -} +}) -function DiffLineRow({ line }: { line: DiffLine }) { - const theme = useTheme() +const DiffLineRow = memo(function DiffLineRow({ + line, + successColor, + errorColor, + tertiaryColor, + textColor, +}: { + line: DiffLine + successColor: string + errorColor: string + tertiaryColor: string + textColor: string +}) { const rowStyle = [ styles.row, line.type === "added" @@ -484,23 +563,23 @@ function DiffLineRow({ line }: { line: DiffLine }) { ? styles.metaRow : null, ] - const textColor = + const color = line.type === "added" - ? theme.colors.success + ? successColor : line.type === "removed" - ? theme.colors.error + ? errorColor : line.type === "meta" - ? theme.colors.textTertiary - : theme.colors.text + ? tertiaryColor + : textColor return ( - {line.leftLineNo ?? ""} - {line.rightLineNo ?? ""} - {line.text || " "} + {line.leftLineNo ?? ""} + {line.rightLineNo ?? ""} + {line.text || " "} ) -} +}) function statusOf(diff: SessionFileDiff) { if (diff.status) return diff.status @@ -546,6 +625,10 @@ function safeParseRows(diff: SessionFileDiff): DiffLine[] { const EMPTY_LINE_ROWS: DiffLine[] = [] +function diffKeyExtractor(item: SessionFileDiff) { + return item.file +} + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/packages/mobile/app/(main)/session/[id]/index.tsx b/packages/mobile/app/(main)/session/[id]/index.tsx index 20868120c782..73f88e975637 100644 --- a/packages/mobile/app/(main)/session/[id]/index.tsx +++ b/packages/mobile/app/(main)/session/[id]/index.tsx @@ -31,6 +31,7 @@ import { ModelPicker } from "../../../../src/components/model-picker" import { DisableFadeProvider } from "../../../../src/animation" import { markChatFirstPaint, markChatInteractionReady, markChatOpenStart } from "../../../../src/perf/chat-metrics" import { addCrashBreadcrumb, readCrashBreadcrumbs } from "../../../../src/perf/crash-breadcrumbs" +import { telemetry } from "../../../../src/perf/telemetry" import { resolveLatestTodoSnapshot } from "../../../../src/components/chat/part" const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string }> @@ -82,9 +83,16 @@ export default function SessionScreen() { const wasSeen = id ? seen.current.has(id) : false const appStateRef = useRef(AppState.currentState) const pollTimerRef = useRef | null>(null) + const firstPaintedRef = useRef(null) const isBusy = sessionStatus?.type === "busy" + const isBusyRef = useRef(isBusy) + isBusyRef.current = isBusy + const pendingCountRef = useRef(pendingRequestCount) + pendingCountRef.current = pendingRequestCount const shouldShowLoadingState = messages.length === 0 && (loadingMessages || loadedAt === 0) const pinnedTodo = useMemo(() => { + if (isBusy) return null + let end = -1 for (let i = messages.length - 1; i >= 0; i -= 1) { const candidate = messages[i] @@ -110,7 +118,7 @@ export default function SessionScreen() { } return null - }, [messages, partsByMessage]) + }, [isBusy, messages, partsByMessage]) useEffect(() => { if (!shouldShowLoadingState) { @@ -173,68 +181,60 @@ export default function SessionScreen() { setPickerVisible(true) }, [fetchProviders]) - useEffect(() => { - if (id) { - addCrashBreadcrumb("session-screen:open", { - sessionID: id, - }) - markChatOpenStart(id) - select(id) - void load(id, { limit: SESSION_OPEN_LIMIT, compact: true, trimToRecent: SESSION_OPEN_LIMIT }) - addCrashBreadcrumb("session-screen:load", { sessionID: id, limit: SESSION_OPEN_LIMIT }) - // Mark as seen after a short delay to let initial content animate - const timer = setTimeout(() => seen.current.add(id), 1500) - const interactionTask = InteractionManager.runAfterInteractions(() => { - markChatInteractionReady(id) - void refreshRequests() - addCrashBreadcrumb("session-screen:interaction-ready", { sessionID: id }) - }) - return () => { - addCrashBreadcrumb("session-screen:cleanup", { sessionID: id }) - clearTimeout(timer) - interactionTask.cancel() - } - } - }, [id, load, refreshRequests, select]) - useEffect(() => { if (!id) return - if (!session?.directory || session.directory === directory) return - let cancelled = false + telemetry.track("session", "session:screen:open", { sessionID: id }) + addCrashBreadcrumb("session-screen:open", { sessionID: id }) + markChatOpenStart(id) + select(id) - addCrashBreadcrumb("session-screen:directory-switch:start", { - sessionID: id, - fromDirectory: directory, - toDirectory: session.directory, - }) + // Reset the first-paint ref so it fires again for a new session + if (firstPaintedRef.current !== id) firstPaintedRef.current = null + + const sessionDir = useSessions.getState().sessions.find((item) => item.id === id)?.directory + const needsSwitch = !!sessionDir && sessionDir !== useConnection.getState().directory - void switchDirectory(session.directory) - .then(() => { - if (cancelled) return - addCrashBreadcrumb("session-screen:directory-switch:done", { + const initSession = async () => { + if (needsSwitch) { + addCrashBreadcrumb("session-screen:directory-switch:start", { sessionID: id, - toDirectory: session.directory, + fromDirectory: useConnection.getState().directory, + toDirectory: sessionDir, }) - // Refresh from the correct directory, but keep render bounded to latest messages. - void load(id, { limit: SESSION_OPEN_LIMIT, compact: true, trimToRecent: SESSION_OPEN_LIMIT }) - }) - .catch((error: unknown) => { - if (cancelled) return - addCrashBreadcrumb( - "session-screen:directory-switch:error", - { - sessionID: id, - toDirectory: session.directory, - message: error instanceof Error ? error.message : String(error), - }, - "warn", - ) - }) + try { + await switchDirectory(sessionDir) + addCrashBreadcrumb("session-screen:directory-switch:done", { sessionID: id, toDirectory: sessionDir }) + } catch (error: unknown) { + addCrashBreadcrumb( + "session-screen:directory-switch:error", + { + sessionID: id, + toDirectory: sessionDir, + message: error instanceof Error ? error.message : String(error), + }, + "warn", + ) + } + } + void load(id, { limit: SESSION_OPEN_LIMIT, trimToRecent: SESSION_OPEN_LIMIT }) + addCrashBreadcrumb("session-screen:load", { sessionID: id, limit: SESSION_OPEN_LIMIT }) + } + + void initSession() + const timer = setTimeout(() => seen.current.add(id), 1500) + const interactionTask = InteractionManager.runAfterInteractions(() => { + markChatInteractionReady(id) + void refreshRequests() + addCrashBreadcrumb("session-screen:interaction-ready", { sessionID: id }) + }) return () => { - cancelled = true + telemetry.track("session", "session:screen:cleanup", { sessionID: id }) + addCrashBreadcrumb("session-screen:cleanup", { sessionID: id }) + clearTimeout(timer) + interactionTask.cancel() } - }, [directory, id, load, session?.directory, switchDirectory]) + }, [id, load, refreshRequests, select, switchDirectory]) useEffect(() => { if (!id) return @@ -285,9 +285,9 @@ export default function SessionScreen() { if (cancelled || appStateRef.current !== "active") return if (!shouldPoll) return - const interval = isBusy + const interval = isBusyRef.current ? REQUEST_POLL_BUSY_MS - : pendingRequestCount > 0 + : pendingCountRef.current > 0 ? REQUEST_POLL_PENDING_MS : REQUEST_POLL_IDLE_MS @@ -322,7 +322,7 @@ export default function SessionScreen() { appStateSubscription.remove() clearPollTimer() } - }, [id, clearPollTimer, isBusy, pendingRequestCount, refreshRequests, stream]) + }, [id, clearPollTimer, refreshRequests, stream]) useEffect(() => { if (!id) return @@ -331,7 +331,6 @@ export default function SessionScreen() { let cancelled = false let timer: ReturnType | null = null let unchangedSyncs = 0 - const baseDelay = isBusy ? MESSAGE_RESYNC_BUSY_MS : MESSAGE_RESYNC_IDLE_MS const sync = async () => { if (cancelled || appStateRef.current !== "active") return @@ -340,7 +339,7 @@ export default function SessionScreen() { const beforeCount = before.messages[id]?.length ?? 0 try { - await load(id, { force: true, limit: SESSION_OPEN_LIMIT, compact: true }) + await load(id, { force: true, limit: SESSION_OPEN_LIMIT }) } catch { // ignore } @@ -355,6 +354,7 @@ export default function SessionScreen() { } else { unchangedSyncs += 1 } + const baseDelay = isBusyRef.current ? MESSAGE_RESYNC_BUSY_MS : MESSAGE_RESYNC_IDLE_MS const delayMultiplier = Math.min(4, 1 + unchangedSyncs) const nextDelay = Math.round(baseDelay * delayMultiplier) timer = setTimeout(() => { @@ -368,10 +368,12 @@ export default function SessionScreen() { cancelled = true if (timer) clearTimeout(timer) } - }, [id, isBusy, load, stream]) + }, [id, load, stream]) useEffect(() => { if (!id || messages.length === 0) return + if (firstPaintedRef.current === id) return + firstPaintedRef.current = id const frame = requestAnimationFrame(() => { markChatFirstPaint(id, messages.length) }) diff --git a/packages/mobile/src/api/events.ts b/packages/mobile/src/api/events.ts index 3cdea82116b3..c0881eccd624 100644 --- a/packages/mobile/src/api/events.ts +++ b/packages/mobile/src/api/events.ts @@ -7,8 +7,16 @@ import { useRequests } from "../store/requests" import { useConnection } from "../store/connection" import { useDiffs } from "../store/diffs" import { markStreamFlush } from "../perf/chat-metrics" +import { addCrashBreadcrumb } from "../perf/crash-breadcrumbs" import { streamFlushPolicy } from "../config/feature-flags" -import { coalesceDeltaBatch, parseSSE, resolveSessionDiffPayload, type AppEvent, type MessagePartDeltaEvent } from "./events.logic" +import { telemetry } from "../perf/telemetry" +import { + coalesceDeltaBatch, + parseSSE, + resolveSessionDiffPayload, + type AppEvent, + type MessagePartDeltaEvent, +} from "./events.logic" // Opt-in debug flag for SSE diagnostics on device: // globalThis.__OPENCODE_MOBILE_SSE_DEBUG__ = true @@ -119,7 +127,6 @@ function applyBatch(events: AppEvent[]) { void messages .load(event.properties.sessionID, { force: true, - compact: true, limit: IDLE_MESSAGE_RESYNC_LIMIT, }) .catch(() => { @@ -144,7 +151,12 @@ function applyBatch(events: AppEvent[]) { console.log("[sse] part.updated", event.properties.part.id, event.properties.part.type) } if (DEBUG && event.type === "message.part.delta") { - console.log("[sse] part.delta", event.properties.partID, event.properties.field, event.properties.delta.length) + console.log( + "[sse] part.delta", + event.properties.partID, + event.properties.field, + event.properties.delta.length, + ) } messageEvents.push(event) break @@ -223,10 +235,13 @@ function connect(current: Subscriber): Promise { const canUseRaf = typeof requestAnimationFrame === "function" && typeof cancelAnimationFrame === "function" if (waitMs > 0 || !canUseRaf) { - timer = setTimeout(() => { - timer = null - flush() - }, waitMs > 0 ? waitMs : EVENT_FLUSH_FALLBACK_MS) + timer = setTimeout( + () => { + timer = null + flush() + }, + waitMs > 0 ? waitMs : EVENT_FLUSH_FALLBACK_MS, + ) return } @@ -266,6 +281,7 @@ function connect(current: Subscriber): Promise { lastProgressAt = Date.now() if (DEBUG) console.log("[sse] connection opened") useConnection.getState().setStream("connected") + telemetry.track("api", "sse:connected") } } @@ -312,6 +328,7 @@ function connect(current: Subscriber): Promise { if (DEBUG) { console.warn("[sse] stall watchdog aborting stream after", idleFor, "ms without progress") } + telemetry.error("api", "sse:stall-abort", { idleMs: idleFor }) try { current.xhr.abort() } catch { @@ -321,6 +338,7 @@ function connect(current: Subscriber): Promise { xhr.onerror = () => { if (DEBUG) console.warn("[sse] xhr error") + telemetry.error("api", "sse:error") if (watchdog) { clearInterval(watchdog) watchdog = null @@ -340,6 +358,7 @@ function connect(current: Subscriber): Promise { xhr.onload = () => { // Stream ended — server closed connection if (DEBUG) console.log("[sse] stream ended (onload)") + telemetry.track("api", "sse:stream-ended") if (watchdog) { clearInterval(watchdog) watchdog = null @@ -402,6 +421,7 @@ export async function subscribe() { if (!current.active) break useConnection.getState().setStream("reconnecting") if (DEBUG) console.warn("[sse] reconnecting in", delay, "ms:", e) + telemetry.track("api", "sse:reconnecting", { delayMs: delay }) await sleep(delay, () => current.active) delay = Math.min(delay * 2, 30000) } diff --git a/packages/mobile/src/components/chat/assistant-message.tsx b/packages/mobile/src/components/chat/assistant-message.tsx index bad5a38c3e74..2765b9ea2398 100644 --- a/packages/mobile/src/components/chat/assistant-message.tsx +++ b/packages/mobile/src/components/chat/assistant-message.tsx @@ -7,7 +7,7 @@ import * as Clipboard from "expo-clipboard" import { useMessageParts } from "../../api/hooks" import { useMessages } from "../../store/messages" import { useTheme } from "../../theme" -import { isTodoToolPart, PartRenderer } from "./part" +import { isTodoToolPart, PartRenderer, GroupedBlurb, groupConsecutiveParts } from "./part" const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string }> @@ -30,8 +30,9 @@ export const AssistantMessage = memo(function AssistantMessage({ const totalTokens = message.tokens.input + message.tokens.output + message.tokens.reasoning const copyKey = useMemo(() => buildCopyCacheKey(parts), [parts]) const canRetry = !!message.time.completed - const visibleParts = useMemo(() => parts.filter((part) => !isTodoToolPart(part)), [parts]) + const visibleParts = useMemo(() => parts.filter((part) => !isTodoToolPart(part) && part.type !== "patch"), [parts]) const activePartID = useMemo(() => resolveActivePartID(visibleParts, canRetry), [canRetry, visibleParts]) + const grouped = useMemo(() => groupConsecutiveParts(visibleParts, activePartID), [visibleParts, activePartID]) const onHydrateMessage = useCallback( (messageID: string) => { void hydrateMessage(message.sessionID, messageID) @@ -60,21 +61,32 @@ export const AssistantMessage = memo(function AssistantMessage({ }) }, [canRetry, message.id, message.sessionID, send]) - if (visibleParts.length === 0 && !diffFooter && !showFooter) return null + if (grouped.length === 0 && !diffFooter && !showFooter) return null return ( - {visibleParts.map((part) => ( - - ))} + {grouped.map((item, i) => + item.kind === "single" ? ( + + ) : ( + + ), + )} {showFooter ? ( @@ -90,7 +102,11 @@ export const AssistantMessage = memo(function AssistantMessage({ accessibilityLabel="Retry response" hitSlop={8} > - + [styles.footerIconButton, pressed && styles.footerIconButtonPressed]} diff --git a/packages/mobile/src/components/chat/composer.tsx b/packages/mobile/src/components/chat/composer.tsx index ff53643b6d5f..4432a7f47d18 100644 --- a/packages/mobile/src/components/chat/composer.tsx +++ b/packages/mobile/src/components/chat/composer.tsx @@ -74,9 +74,12 @@ export function Composer({ sessionId, pinnedTodo = null }: Props) { abort(sessionId) }, [sessionId, abort]) - const handleLayout = useCallback((e: LayoutChangeEvent) => { - setComposerH(Math.round(e.nativeEvent.layout.height)) - }, [setComposerH]) + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + setComposerH(Math.round(e.nativeEvent.layout.height)) + }, + [setComposerH], + ) const Sticky = KeyboardStickyView as React.ComponentType<{ offset?: { closed?: number; opened?: number } @@ -84,7 +87,11 @@ export function Composer({ sessionId, pinnedTodo = null }: Props) { }> const sendButton = busy ? ( - + ) : ( @@ -192,7 +199,7 @@ const styles = StyleSheet.create({ alignItems: "flex-end", paddingLeft: 14, paddingRight: 6, - paddingVertical: 3, + paddingVertical: 6, }, input: { flex: 1, diff --git a/packages/mobile/src/components/chat/list.tsx b/packages/mobile/src/components/chat/list.tsx index 8b841fd47419..fcf1d318540b 100644 --- a/packages/mobile/src/components/chat/list.tsx +++ b/packages/mobile/src/components/chat/list.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { ActivityIndicator, Platform, @@ -24,12 +24,14 @@ import type { SessionFileDiff } from "../../features/diff/types" import { normalizeDiffList } from "../../features/diff/normalize" import { synthesizeSessionDiff } from "../../features/diff/synthetic" import { addCrashBreadcrumb } from "../../perf/crash-breadcrumbs" +import { telemetry } from "../../perf/telemetry" import { useSessionDiffSummary, useSessionPartsMap } from "../../api/hooks" import { useChat } from "./provider" import { useTheme } from "../../theme" import { UserMessage } from "./user-message" import { AssistantMessage } from "./assistant-message" import { DiffSummaryCard } from "./diff-summary-card" +import { TurnDiffIndicator } from "./turn-diff-indicator" import { hasChanges, resolveEffectiveSessionData, @@ -42,15 +44,17 @@ import { const STREAM_AUTOFOLLOW_MIN_GROWTH = 8 const JUMP_TO_BOTTOM_DISTANCE = 320 -const OLDER_PREFETCH_OFFSET_PX = 1600 -const OLDER_PREFETCH_THROTTLE_MS = 320 -const TOP_LOAD_LOCK_OFFSET_PX = 10 const SYNTHETIC_FALLBACK_MAX_MESSAGES = 120 const TURN_DIFF_SYNTHESIS_MAX_TURNS = 18 const TURN_DIFF_SYNTHESIS_LARGE_SESSION_CUTOFF = 220 const TURN_DIFF_WINDOW_MAX_MESSAGES = 320 const AnimatedView = Animated.View as React.ComponentType<{ style?: unknown; children?: React.ReactNode }> -const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string; style?: unknown }> +const FeatherIcon = Feather as unknown as React.ComponentType<{ + name: string + size: number + color: string + style?: unknown +}> const Glass = LiquidGlassView as React.ComponentType<{ interactive?: boolean style?: unknown @@ -59,6 +63,42 @@ const Glass = LiquidGlassView as React.ComponentType<{ const EMPTY_DIFFS: SessionFileDiff[] = [] const EMPTY_DIFF_FOOTERS: DiffFooterMeta[] = [] +const EMPTY_TURN_FOOTER_MAP = new Map() +const IDLE_MVCP = {} + +const DiffFooterGroup = memo(function DiffFooterGroup({ + footerMetas, + onOpenDiff, +}: { + footerMetas: DiffFooterMeta[] + onOpenDiff: (meta: DiffFooterMeta) => void +}) { + if (footerMetas.length === 0) return null + return ( + + {footerMetas.map((meta, i) => + meta.mode === "turn" ? ( + onOpenDiff(meta)} + /> + ) : ( + onOpenDiff(meta)} + /> + ), + )} + + ) +}) + type Props = { sessionId: string messages: Message[] @@ -98,14 +138,10 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { const raf = useRef(null) const bottomSettleTimerRef = useRef | null>(null) const loadingMoreRef = useRef(false) - const loadingOlderVisibleRef = useRef(false) const userInteractingRef = useRef(false) const momentumActiveRef = useRef(false) const contentHeightRef = useRef(0) - const lastScrollOffsetYRef = useRef(0) - const topLockDuringLoadRef = useRef(false) const jumpVisibleRef = useRef(false) - const lastOlderPrefetchAtRef = useRef(0) const malformedMessageKeysRef = useRef(new Set()) const logMalformedMessage = useCallback( @@ -132,12 +168,6 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { ) const count = messages.length - const olderPrefetchOffset = useMemo(() => { - if (count >= 2000) return 2800 - if (count >= 1200) return 2200 - if (count >= 600) return 1800 - return OLDER_PREFETCH_OFFSET_PX - }, [count]) const latestAssistantIndex = useMemo(() => { for (let i = messages.length - 1; i >= 0; i -= 1) { const candidate = messages[i] @@ -221,7 +251,9 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { const latestAssistant = latestAssistantIndex >= 0 ? messages[latestAssistantIndex] : undefined const latestAssistantID = - latestAssistant && latestAssistant.role === "assistant" && typeof latestAssistant.id === "string" ? latestAssistant.id : "" + latestAssistant && latestAssistant.role === "assistant" && typeof latestAssistant.id === "string" + ? latestAssistant.id + : "" const latestAssistantCreatedAt = useMemo(() => { if (!latestAssistant || latestAssistant.role !== "assistant") return 0 return Math.max(0, asNumber(latestAssistant.time?.created)) @@ -290,6 +322,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { ) const turnFooterByAssistantID = useMemo(() => { + if (isBusy) return EMPTY_TURN_FOOTER_MAP const result = new Map() const entries = Array.from(assistantMessagesByParentID.entries()) const maxTurns = messages.length > TURN_DIFF_SYNTHESIS_LARGE_SESSION_CUTOFF ? 1 : TURN_DIFF_SYNTHESIS_MAX_TURNS @@ -323,7 +356,15 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { if (turnFooterMeta) result.set(targetAssistant.id, turnFooterMeta) } return result - }, [assistantMessagesByParentID, isBusy, latestAssistantID, messages.length, partsByMessage, userMessagesByID, userSummaryDiffsByID]) + }, [ + assistantMessagesByParentID, + isBusy, + latestAssistantID, + messages.length, + partsByMessage, + userMessagesByID, + userSummaryDiffsByID, + ]) const diffFootersByAssistantID = useMemo(() => { return resolveFootersByAssistant({ @@ -392,11 +433,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { userInteractingRef.current = false momentumActiveRef.current = false contentHeightRef.current = 0 - lastScrollOffsetYRef.current = 0 - topLockDuringLoadRef.current = false jumpVisibleRef.current = false - loadingOlderVisibleRef.current = false - lastOlderPrefetchAtRef.current = 0 setShowJump(false) setLoadingOlder(false) if (raf.current !== null) { @@ -441,21 +478,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { message={item} showFooter={index === latestAssistantIndex} diffFooter={ - footerMetas.length > 0 ? ( - - {footerMetas.map((meta, footerIndex) => ( - openDiff(meta)} - /> - ))} - - ) : null + footerMetas.length > 0 ? : null } /> ) @@ -465,53 +488,38 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { [diffFootersByAssistantID, latestAssistantIndex, logMalformedMessage, openDiff], ) - const keyExtractor = useCallback((item: Message, index: number) => { - if (item && typeof item.id === "string" && item.id.length > 0) return item.id - logMalformedMessage("key-extractor-fallback", item, index) - return `unknown-message-${index}` - }, [logMalformedMessage]) - - const getItemType = useCallback((item: Message) => { - if (item && typeof item.role === "string") return item.role - logMalformedMessage("get-item-type-fallback", item, -1) - return "unknown" - }, [logMalformedMessage]) + const keyExtractor = useCallback( + (item: Message, index: number) => { + if (item && typeof item.id === "string" && item.id.length > 0) return item.id + logMalformedMessage("key-extractor-fallback", item, index) + return `unknown-message-${index}` + }, + [logMalformedMessage], + ) - const triggerLoadMore = useCallback( - function triggerLoadMoreImpl(showLoader: boolean, allowFollowup = true) { - if (!didInitialScroll.current) return - if (loadingMoreRef.current || exhaustedSession) return - loadingMoreRef.current = true - topLockDuringLoadRef.current = false - loadingOlderVisibleRef.current = showLoader - if (showLoader) setLoadingOlder(true) - void loadMore(sessionId).finally(() => { - loadingMoreRef.current = false - topLockDuringLoadRef.current = false - if (allowFollowup && !exhaustedSession && lastScrollOffsetYRef.current <= olderPrefetchOffset * 0.45) { - // If the user is still very close to the top, fetch one extra page to avoid blank gaps. - triggerLoadMoreImpl(true, false) - return - } - if (loadingOlderVisibleRef.current && !loadingMoreRef.current) { - loadingOlderVisibleRef.current = false - setLoadingOlder(false) - } - }) + const getItemType = useCallback( + (item: Message) => { + if (item && typeof item.role === "string") return item.role + logMalformedMessage("get-item-type-fallback", item, -1) + return "unknown" }, - [exhaustedSession, loadMore, olderPrefetchOffset, sessionId], + [logMalformedMessage], ) + const handleLoadMore = useCallback(() => { + if (loadingMoreRef.current || exhaustedSession) return + loadingMoreRef.current = true + setLoadingOlder(true) + telemetry.track("chat", "chat:loadMore", { sessionID: sessionId }) + void loadMore(sessionId).finally(() => { + loadingMoreRef.current = false + setLoadingOlder(false) + }) + }, [exhaustedSession, loadMore, sessionId]) + const handleScroll = useCallback( (e: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent - lastScrollOffsetYRef.current = contentOffset.y - if (loadingMoreRef.current && contentOffset.y < TOP_LOAD_LOCK_OFFSET_PX) { - if (!topLockDuringLoadRef.current) { - topLockDuringLoadRef.current = true - listRef.current?.scrollToOffset({ offset: TOP_LOAD_LOCK_OFFSET_PX, animated: false }) - } - } const distance = contentSize.height - contentOffset.y - layoutMeasurement.height isAtEnd.value = distance < 150 const shouldShow = distance > JUMP_TO_BOTTOM_DISTANCE @@ -519,29 +527,13 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { jumpVisibleRef.current = shouldShow setShowJump(shouldShow) } - - if ( - contentOffset.y <= olderPrefetchOffset && - didInitialScroll.current && - !loadingMoreRef.current && - !exhaustedSession - ) { - const now = Date.now() - if (now - lastOlderPrefetchAtRef.current >= OLDER_PREFETCH_THROTTLE_MS) { - lastOlderPrefetchAtRef.current = now - triggerLoadMore(true) - } - } }, - [exhaustedSession, isAtEnd, olderPrefetchOffset, triggerLoadMore], + [isAtEnd], ) const onStartReached = useCallback(() => { - const now = Date.now() - if (now - lastOlderPrefetchAtRef.current < OLDER_PREFETCH_THROTTLE_MS) return - lastOlderPrefetchAtRef.current = now - triggerLoadMore(true) - }, [triggerLoadMore]) + // no-op: loading is manual via the "Load more" button + }, []) const onScrollBeginDrag = useCallback(() => { userInteractingRef.current = true @@ -567,6 +559,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { (_width: number, height: number) => { const previousHeight = contentHeightRef.current contentHeightRef.current = height + if (initialBottomSyncPendingRef.current && didInitialScroll.current && count > 0) { initialBottomSyncPendingRef.current = false scheduleScrollToEnd(false) @@ -606,7 +599,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { ) const maintainVisibleContentPosition = useMemo(() => { - if (!isBusy) return undefined + if (!isBusy) return IDLE_MVCP return { startRenderingFromBottom: true, autoscrollToTopThreshold: count < 1200 ? 0.2 : undefined, @@ -619,9 +612,8 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { if (count >= 2400) return Platform.OS === "ios" ? 1800 : 1700 if (count >= 1200) return Platform.OS === "ios" ? 1400 : 1320 if (count >= 500) return Platform.OS === "ios" ? 1000 : 940 - if (isBusy) return Platform.OS === "ios" ? 760 : 720 - return Platform.OS === "ios" ? 460 : 500 - }, [count, isBusy]) + return Platform.OS === "ios" ? 760 : 720 + }, [count]) const jumpBottom = Math.max(composerH + 14, insets.bottom + 60) const onListLoad = useCallback(() => { @@ -629,30 +621,47 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { didInitialScroll.current = true if (firstLoad) { prevCount.current = count - if (count > 0) { - scheduleScrollToEnd(false) - } + // initialScrollIndex already positions the list at the bottom on first render, + // so no explicit scrollToEnd needed here. } addCrashBreadcrumb("chat-list:on-load", { sessionID: sessionId, count }) - }, [count, scheduleScrollToEnd, sessionId]) + }, [count, sessionId]) const listHeader = useMemo(() => { - if (exhaustedSession || !loadingOlder) return null + if (exhaustedSession) return null return ( - [ + styles.loadMoreButton, { - borderColor: theme.colors.borderSubtle, - backgroundColor: theme.colors.background, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surfaceRaised, }, + pressed && styles.loadMorePressed, ]} + onPress={handleLoadMore} + disabled={loadingOlder} + accessibilityRole="button" + accessibilityLabel="Load older messages" > - - Loading older messages... - + {loadingOlder ? ( + + ) : ( + + )} + + {loadingOlder ? "Loading..." : "Load older messages"} + + ) - }, [exhaustedSession, loadingOlder, theme.colors.background, theme.colors.borderSubtle, theme.colors.textSecondary]) + }, [ + exhaustedSession, + handleLoadMore, + loadingOlder, + theme.colors.border, + theme.colors.surfaceRaised, + theme.colors.textSecondary, + ]) useEffect(() => { if (!didInitialScroll.current) { @@ -665,7 +674,13 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { return } - if (isBusy && count > prevCount.current && isAtEnd.value && !userInteractingRef.current && !momentumActiveRef.current) { + if ( + isBusy && + count > prevCount.current && + isAtEnd.value && + !userInteractingRef.current && + !momentumActiveRef.current + ) { scheduleScrollToEnd(false) } prevCount.current = count @@ -689,6 +704,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { onContentSizeChange={onContentSizeChange} onLoad={onListLoad} scrollEventThrottle={16} + initialScrollIndex={count > 0 ? count - 1 : undefined} drawDistance={drawDistance} onStartReached={onStartReached} onStartReachedThreshold={0.7} @@ -699,7 +715,6 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { alwaysBounceVertical={false} overScrollMode="never" maintainVisibleContentPosition={maintainVisibleContentPosition} - removeClippedSubviews={Platform.OS === "ios" ? false : true} ListHeaderComponent={listHeader} ListFooterComponent={ !latestAssistant && sessionFooterMeta ? ( @@ -720,6 +735,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { [styles.jumpButton, pressed && styles.jumpPressed]} onPress={() => { + telemetry.track("chat", "chat:jumpToBottom", { sessionID: sessionId }) userInteractingRef.current = false momentumActiveRef.current = false scheduleScrollToEnd(false) @@ -744,6 +760,7 @@ export function MessagesList({ sessionId, messages, topPadding }: Props) { }, ]} onPress={() => { + telemetry.track("chat", "chat:jumpToBottom", { sessionID: sessionId }) userInteractingRef.current = false momentumActiveRef.current = false scheduleScrollToEnd(false) @@ -774,19 +791,22 @@ const styles = StyleSheet.create({ diffFooterStack: { gap: 2, }, - olderLoadingHeader: { + loadMoreButton: { alignSelf: "center", marginBottom: 10, borderWidth: StyleSheet.hairlineWidth, borderRadius: 999, - paddingHorizontal: 10, - paddingVertical: 6, + paddingHorizontal: 14, + paddingVertical: 8, flexDirection: "row", alignItems: "center", - gap: 8, + gap: 6, minHeight: 30, }, - olderLoadingText: { + loadMorePressed: { + opacity: 0.6, + }, + loadMoreText: { fontSize: 12, fontWeight: "500", }, diff --git a/packages/mobile/src/components/chat/part.tsx b/packages/mobile/src/components/chat/part.tsx index 0be9357118cf..eccd1f3dba2e 100644 --- a/packages/mobile/src/components/chat/part.tsx +++ b/packages/mobile/src/components/chat/part.tsx @@ -1,5 +1,5 @@ import { memo, useState, useCallback, useEffect, useMemo } from "react" -import { View, Text, Pressable, StyleSheet, Linking, Platform, useWindowDimensions } from "react-native" +import { View, Text, Pressable, StyleSheet, ScrollView, Linking, Platform, useWindowDimensions } from "react-native" import Feather from "@expo/vector-icons/Feather" import { LinearGradient } from "expo-linear-gradient" import Animated, { @@ -25,12 +25,13 @@ import type { ToolPart, } from "@opencode-ai/sdk/client" import { useTheme } from "../../theme" +import { telemetry } from "../../perf/telemetry" import { MarkdownRenderer } from "../markdown/renderer" import { CodeBlock } from "../markdown/code-block" type SubtaskPart = Extract type CompactionPart = Extract -type BlurbAction = "thought" | "created" | "ran" | "explored" +export type BlurbAction = "thought" | "created" | "ran" | "read" export type TodoItem = { id: string content: string @@ -49,7 +50,12 @@ type Palette = ReturnType["colors"] const AnimatedView: any = Animated.View const AnimatedLinearGradient: any = Animated.createAnimatedComponent(LinearGradient) -const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string; style?: unknown }> +const FeatherIcon = Feather as unknown as React.ComponentType<{ + name: string + size: number + color: string + style?: unknown +}> const COLLAPSIBLE_LAYOUT = LinearTransition.springify().damping(22).stiffness(260).mass(0.7) const COLLAPSIBLE_ENTER = FadeIn.duration(140).easing(Easing.out(Easing.cubic)) const COLLAPSIBLE_EXIT = FadeOut.duration(110).easing(Easing.in(Easing.cubic)) @@ -57,7 +63,7 @@ const ACTION_WORD: Record = { thought: "Thought about", created: "Created", ran: "Ran", - explored: "Explored", + read: "Read", } const TODO_STATUS_ORDER: Record = { in_progress: 0, @@ -70,13 +76,15 @@ const TODO_PRIORITY_ORDER: Record = { medium: 1, low: 2, } -const READ_BLURB_MAX_CHARS = 6_000 +const READ_BLURB_MAX_CHARS = 3_000 +const REASONING_CAP_CHARS = 1_500 type Props = { part: Part isUser: boolean isActive?: boolean isStreamingComplete?: boolean + isNested?: boolean onHydrateMessage?: (messageID: string) => void } @@ -85,13 +93,21 @@ export const PartRenderer = memo(function PartRenderer({ isUser, isActive = false, isStreamingComplete = true, + isNested = false, onHydrateMessage, }: Props) { switch (part.type) { case "text": return case "reasoning": - return + return ( + + ) case "tool": return case "file": @@ -117,7 +133,15 @@ export const PartRenderer = memo(function PartRenderer({ } }) -function TextPartView({ part, isUser, isStreamingComplete }: { part: TextPart; isUser: boolean; isStreamingComplete: boolean }) { +function TextPartView({ + part, + isUser, + isStreamingComplete, +}: { + part: TextPart + isUser: boolean + isStreamingComplete: boolean +}) { const theme = useTheme() if (!part.text) return null @@ -128,7 +152,7 @@ function TextPartView({ part, isUser, isStreamingComplete }: { part: TextPart; i return {part.text} } -function BlurbRow({ +export function BlurbRow({ label, action, isActive = false, @@ -146,15 +170,13 @@ function BlurbRow({ const theme = useTheme() const { width: screenWidth } = useWindowDimensions() const color = blurbActionColor(action, theme.colors) - const verb = ACTION_WORD[action] - const lower = label.toLowerCase() - const lowerVerb = verb.toLowerCase() const shimmerX = useSharedValue(-screenWidth) const activeProgress = useSharedValue(isActive ? 1 : 0) - const subject = - lower.startsWith(lowerVerb) && label.length > verb.length - ? label.slice(verb.length).trimStart() - : label + + // Extract verb + subject from label. If the label already starts with a + // recognized verb (e.g. "Edited list.tsx"), use that verb directly instead + // of the generic ACTION_WORD for the action — avoids "Created Edited …". + const { verb, subject } = splitVerbSubject(label, action) useEffect(() => { activeProgress.value = withTiming(isActive ? 1 : 0, { @@ -231,12 +253,16 @@ function BlurbRow({ {verb} {subject ? ( - + {" "} {subject} ) : ( - + {label} )} @@ -253,12 +279,14 @@ function BlurbRow({ ]} > - Live ) : null} {showChevron ? ( - + ) : null} @@ -267,13 +295,7 @@ function BlurbRow({ ) } -function CollapsibleContent({ - children, - style, -}: { - children: React.ReactNode - style?: unknown -}) { +function CollapsibleContent({ children, style }: { children: React.ReactNode; style?: unknown }) { const theme = useTheme() return ( @@ -299,20 +321,30 @@ function ReasoningPartView({ part, isActive = false, isStreamingComplete, + isNested = false, }: { part: ReasoningPart isActive?: boolean isStreamingComplete: boolean + isNested?: boolean }) { + const theme = useTheme() const [expanded, setExpanded] = useState(false) + const [showFull, setShowFull] = useState(false) const text = part.text?.trim() if (!text) return null + const capped = !showFull && text.length > REASONING_CAP_CHARS + const visible = capped ? text.slice(0, REASONING_CAP_CHARS) : text + const toggle = useCallback(() => { setExpanded((v) => !v) }, []) - const label = summarizeBlurb("thought", summarizeInline(text, 52)) + // nested: show first 3 words of reasoning content; standalone: show duration + const label = isNested + ? thinkingSnippet(text) + : `Thought for ${formatThoughtDuration(part.time.start, part.time.end)}` return ( @@ -320,8 +352,21 @@ function ReasoningPartView({ {expanded ? ( - {text} + {visible} + {capped ? ( + { + telemetry.track("ui", "reasoning:showMore", { chars: text.length }) + setShowFull(true) + }} + style={styles.reasoningShowMore} + > + + Show more ({Math.ceil((text.length - REASONING_CAP_CHARS) / 100) * 100}+ chars) + + + ) : null} ) : null} @@ -349,21 +394,18 @@ function ToolPartView({ const toggle = useCallback(() => { const opening = !expanded - if ( - opening && - status === "completed" && - "outputTruncated" in part.state && - part.state.outputTruncated - ) { + telemetry.track("ui", opening ? "tool:expand" : "tool:collapse", { tool: toolName }) + if (opening && status === "completed" && "outputTruncated" in part.state && part.state.outputTruncated) { requestHydration() } setExpanded((v) => !v) - }, [expanded, part.state, requestHydration, status]) + }, [expanded, part.state, requestHydration, status, toolName]) if (isTodoToolPart(part)) return null const summary = summarizeToolLabel(part, title || toolName, todos) - const open = expanded + const isRead = summary.action === "read" + const open = expanded && !isRead return ( @@ -372,16 +414,12 @@ function ToolPartView({ action={summary.action} isActive={isActive} expanded={open} - onPress={toggle} - showChevron + onPress={isRead ? undefined : toggle} + showChevron={!isRead} /> {open ? ( - + ) : null} @@ -401,15 +439,26 @@ function ToolDetailView({ const status = part.state.status const elapsed = toolElapsed(part) const output = - status === "completed" && "output" in part.state && typeof part.state.output === "string" - ? part.state.output - : "" + status === "completed" && "output" in part.state && typeof part.state.output === "string" ? part.state.output : "" const truncatedOutput = status === "completed" && "outputTruncated" in part.state && typeof part.state.outputTruncated === "boolean" ? part.state.outputTruncated : false - const read = action === "explored" && part.tool === "read" && output ? parseReadOutput(output) : null + const read = useMemo(() => { + if (action !== "read" || part.tool !== "read" || !output) return null + return parseReadOutput(output) + }, [action, part.tool, output]) + + const readCode = useMemo(() => { + if (!read?.content) return null + return trimOutput(stripReadLineNumbers(read.content), READ_BLURB_MAX_CHARS) + }, [read]) + + const readLanguage = useMemo(() => { + if (!read?.path) return "text" + return languageFromPath(read.path) + }, [read]) if (action === "ran") { const command = toolCommand(part) @@ -436,7 +485,7 @@ function ToolDetailView({ ) : null} {command ? : null} - {trimOutput(body, 2600)} + {trimOutput(body, 1600)} @@ -451,27 +500,48 @@ function ToolDetailView({ ) } - if (read?.type === "file" && read.content) { - const code = trimOutput(stripReadLineNumbers(read.content), READ_BLURB_MAX_CHARS) - const language = languageFromPath(read.path) - return ( - - {read.path ? ( - - {read.path} - - ) : null} - - - {elapsed ? {elapsed} : null} - {truncatedOutput ? ( + if (action === "read" && part.tool === "read" && output) { + if (read?.type === "file" && readCode) { + return ( + + {read.path ? ( + + {read.path} + + ) : null} + + + {elapsed ? {elapsed} : null} + {truncatedOutput ? ( + + Load full output + + ) : null} + + + ) + } + + if (!read && truncatedOutput) { + return ( + + + Loading file contents... + + + {elapsed ? {elapsed} : null} Load full output - ) : null} + - - ) + ) + } } const content = @@ -496,7 +566,7 @@ function ToolDetailView({ ]} selectable > - {trimOutput(content || "No details available", 2200)} + {trimOutput(content || "No details available", 1600)} @@ -545,11 +615,15 @@ export function TodoPanel({ return ( - + - - {completedSummary} - + {completedSummary} {live ? ( {onToggle ? ( - - - + ) : null} - + {!expanded ? null : snapshot.todos.length === 0 ? ( No todos yet ) : ( - - {snapshot.todos.map((todo) => ( - - ))} - + + + {snapshot.todos.map((todo) => ( + + ))} + + )} ) @@ -607,7 +681,6 @@ function TodoRow({ todo }: { todo: TodoItem }) { > {todo.content} - {todoStatusLabel(todo.status)} ) } @@ -833,6 +906,37 @@ function CollapsibleChevron({ expanded, color }: { expanded: boolean; color: str ) } +const VERB_RE = /^(Thought (?:for|about)|Created|Edited|Ran|Read)\s+/i + +function splitVerbSubject(label: string, action: BlurbAction) { + const match = label.match(VERB_RE) + if (match) return { verb: match[1], subject: label.slice(match[0].length) } + return { verb: ACTION_WORD[action], subject: label } +} + +function thinkingSnippet(text: string) { + // first 3 words of the reasoning text, max ~32 chars + const words = text.replace(/\s+/g, " ").trim().split(" ") + const snippet = words.slice(0, 3).join(" ") + return snippet.length > 32 ? `${snippet.slice(0, 29)}…` : snippet +} + +function fileBasename(value: string) { + // strip any path separators, return just the filename portion + const trimmed = value.trim() + const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + return slash >= 0 ? trimmed.slice(slash + 1) : trimmed +} + +function formatThoughtDuration(start: number, end?: number) { + const ms = (end ?? Date.now()) - start + const secs = Math.round(ms / 1000) + if (secs < 60) return `${secs}s` + const m = Math.floor(secs / 60) + const s = secs % 60 + return s === 0 ? `${m}m` : `${m}m ${s}s` +} + function short(value: string) { if (value.length <= 32) return value return `${value.slice(0, 16)}...${value.slice(-8)}` @@ -849,16 +953,23 @@ function defaultToolSubject(tool: string) { const normalized = tool.toLowerCase() if (normalized === "todowrite" || normalized === "todoread") return "todo list" if (normalized.includes("glob") || normalized.includes("list") || normalized.includes("ls")) return "project files" - if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("command")) return "terminal command" - if (normalized.includes("grep") || normalized.includes("find") || normalized.includes("search")) return "relevant matches" + if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("command")) + return "terminal command" + if (normalized.includes("grep") || normalized.includes("find") || normalized.includes("search")) + return "relevant matches" if (normalized.includes("fetch") || normalized.includes("web")) return "web results" if (normalized.includes("read") || normalized.includes("cat")) return "file contents" - if (normalized.includes("patch") || normalized.includes("edit") || normalized.includes("write")) return "project changes" + if (normalized.includes("patch") || normalized.includes("edit") || normalized.includes("write")) + return "project changes" if (normalized.includes("task")) return "delegated task" if (normalized.includes("question")) return "a clarification" return tool } +function isEditTool(tool: string) { + return /(edit|apply_patch|multiedit|sed|patch)/.test(tool.toLowerCase()) +} + function summarizeToolLabel(part: ToolPart, title: string, todos: TodoItem[]) { const normalizedTitle = title.replace(/\s+/g, " ").trim() const shouldUseTitle = normalizedTitle.length > 0 && normalizedTitle.toLowerCase() !== part.tool.toLowerCase() @@ -867,6 +978,19 @@ function summarizeToolLabel(part: ToolPart, title: string, todos: TodoItem[]) { if (known) return { label: capitalizeInline(raw), action: known } const action = inferToolAction(part.tool, normalizedTitle, todos) const subject = part.tool === "todowrite" || part.tool === "todoread" ? "todo list" : raw + // for read-action tools, show just the filename + if (action === "read") { + const name = fileBasename(subject) + return { label: `Read ${name || subject}`, action } + } + // for created-action tools, distinguish "Edited" vs "Created" and show filename + if (action === "created") { + const verb = isEditTool(part.tool) ? "Edited" : "Created" + const name = fileBasename(subject) + // only show filename when subject looks like a path (contains / or \) + const display = subject.includes("/") || subject.includes("\\") ? name : summarizeInline(subject, 52) + return { label: `${verb} ${display}`, action } + } return { label: summarizeBlurb(action, summarizeInline(subject, 52)), action } } @@ -878,10 +1002,10 @@ function summarizeBlurb(action: BlurbAction, subject: string) { function parseBlurbAction(value: string): BlurbAction | null { const normalized = value.trim().toLowerCase() - if (normalized.startsWith("thought about ")) return "thought" + if (normalized.startsWith("thought about ") || normalized.startsWith("thought for ")) return "thought" if (normalized.startsWith("created ")) return "created" if (normalized.startsWith("ran ")) return "ran" - if (normalized.startsWith("explored ")) return "explored" + if (normalized.startsWith("read ") || normalized.startsWith("explored ")) return "read" return null } @@ -898,16 +1022,291 @@ function inferToolAction(tool: string, title: string, todos: TodoItem[]): BlurbA return "ran" } - if (normalizedTool === "todoread") return "explored" + if (normalizedTool === "todoread") return "read" if (/(write|edit|patch|apply_patch|multiedit|create|mkdir|mv|cp|save|rename)/.test(normalizedTool)) return "created" if (/(bash|shell|command|task|batch|question|plan)/.test(normalizedTool)) return "ran" - if (/(read|cat|glob|list|ls|grep|find|search|fetch|web|code|lsp)/.test(normalizedTool)) return "explored" + if (/(read|cat|glob|list|ls|grep|find|search|fetch|web|code|lsp)/.test(normalizedTool)) return "read" if (/^(created?|updated?|edited?|wrote|saved)\b/.test(normalizedTitle)) return "created" if (/^(ran|running|executed|launched)\b/.test(normalizedTitle)) return "ran" - if (/^(explored|searched|read|listed|fetched)\b/.test(normalizedTitle)) return "explored" + if (/^(explored|searched|read|listed|fetched)\b/.test(normalizedTitle)) return "read" return "thought" } +export function inferPartAction(part: Part): BlurbAction | null { + if (part.type === "text" || part.type === "step-start" || part.type === "step-finish") return null + if (part.type === "reasoning" || part.type === "subtask") return "thought" + if (part.type === "file" || part.type === "snapshot" || part.type === "patch") return "created" + if (part.type === "agent" || part.type === "retry" || part.type === "compaction") return "ran" + if (part.type === "tool") { + const title = "title" in part.state && typeof part.state.title === "string" ? part.state.title : "" + const todos = isTodoToolPart(part) ? extractToolTodos(part) : [] + return summarizeToolLabel(part, title, todos).action + } + return null +} + +const GROUP_SUBJECT: Record = { + read: "files", + created: "files", + ran: "commands", + thought: "topics", +} + +// Short text/step-start/step-finish parts between blurbs don't break a group +const SHORT_TEXT_THRESHOLD = 600 +const MEDIUM_TEXT_THRESHOLD = 2000 +const RICH_TEXT_RE = /^#{1,4}\s|```|^\s*[-*]\s/m + +export type PartItem = + | { kind: "single"; part: Part } + | { kind: "group"; action: BlurbAction; parts: Part[]; all: Part[] } + +export function groupConsecutiveParts(parts: Part[], activePartID: string): PartItem[] { + // Two-pass grouping: + // Pass 1 — tag each part as blurb, bridge, skip, or break + // Pass 2 — merge consecutive blurb runs (with bridged gaps) into groups + + const tagged: { part: Part; tag: "blurb" | "bridge" | "break" }[] = [] + + for (const part of parts) { + if (part.type === "step-start" || part.type === "step-finish") continue + if (activePartID && part.id === activePartID) { + tagged.push({ part, tag: "break" }) + continue + } + const action = inferPartAction(part) + if (action !== null) { + tagged.push({ part, tag: "blurb" }) + continue + } + // text parts between blurbs can bridge — mark tentatively, + // pass 2 will decide whether they actually bridge or break. + // Short text (< 600 chars) always bridges. + // Medium text (600-2000 chars) bridges if it has no rich content (headers, code blocks, lists). + if (part.type === "text") { + const len = part.text?.trim().length ?? 0 + if (len < SHORT_TEXT_THRESHOLD || (len < MEDIUM_TEXT_THRESHOLD && !RICH_TEXT_RE.test(part.text ?? ""))) { + tagged.push({ part, tag: "bridge" }) + continue + } + } + tagged.push({ part, tag: "break" }) + } + + // Pass 2 — sweep and collect runs of blurbs (allowing bridge gaps between them) + const result: PartItem[] = [] + let i = 0 + + while (i < tagged.length) { + const entry = tagged[i] + + if (entry.tag !== "blurb") { + result.push({ kind: "single", part: entry.part }) + i++ + continue + } + + // Start of a potential blurb run — collect all blurbs and bridges + const blurbs: Part[] = [entry.part] + const all: Part[] = [entry.part] + i++ + + while (i < tagged.length) { + if (tagged[i].tag === "blurb") { + blurbs.push(tagged[i].part) + all.push(tagged[i].part) + i++ + continue + } + + if (tagged[i].tag === "bridge") { + // lookahead: only bridge if a blurb follows before a break + let j = i + const pending: Part[] = [] + while (j < tagged.length && tagged[j].tag === "bridge") { + pending.push(tagged[j].part) + j++ + } + if (j < tagged.length && tagged[j].tag === "blurb") { + // confirmed bridge — absorb pending into group + all.push(...pending) + i = j + continue + } + // bridge leads to break or end — stop run, leave pending for individual emission + break + } + + // break — stop the run + break + } + + if (blurbs.length < 2) { + for (const p of all) result.push({ kind: "single", part: p }) + } else { + result.push({ kind: "group", action: pickDominantAction(blurbs), parts: blurbs, all }) + } + } + + if (__DEV__) { + const blurbCount = tagged.filter((t) => t.tag === "blurb").length + const groupCount = result.filter((r) => r.kind === "group").length + if (blurbCount >= 2 && groupCount === 0) { + const trace = tagged.map((t, idx) => { + const extra = + t.part.type === "text" + ? ` len=${(t.part as TextPart).text?.length ?? 0}` + : t.part.type === "tool" + ? ` tool=${(t.part as ToolPart).tool}` + : "" + return `[${idx}] ${t.tag} type=${t.part.type}${extra} id=${t.part.id.slice(0, 16)}` + }) + console.warn(`[groupParts] ${blurbCount} blurbs but 0 groups!\n${trace.join("\n")}`) + } + } + + return result +} + +function pickDominantAction(blurbs: Part[]): BlurbAction { + const counts: Record = { created: 0, ran: 0, read: 0, thought: 0 } + for (const p of blurbs) { + const a = inferPartAction(p) + if (a) counts[a]++ + } + let best: BlurbAction = "created" + let max = 0 + for (const [action, count] of Object.entries(counts) as [BlurbAction, number][]) { + if (count > max) { + max = count + best = action as BlurbAction + } + } + return best +} + +export function GroupedBlurb({ + action, + parts, + all, + isStreamingComplete, + onHydrateMessage, +}: { + action: BlurbAction + parts: Part[] + all: Part[] + isStreamingComplete: boolean + onHydrateMessage?: (messageID: string) => void +}) { + const [expanded, setExpanded] = useState(false) + const toggle = useCallback(() => setExpanded((v) => !v), []) + const label = groupLabel(action, parts) + + return ( + + + {expanded ? ( + + + {all.map((part, idx) => ( + + ))} + + + ) : null} + + ) +} + +function groupLabel(action: BlurbAction, parts: Part[]): string { + const counts: Record = { created: 0, ran: 0, read: 0, thought: 0 } + for (const p of parts) { + const a = inferPartAction(p) + if (a) counts[a]++ + } + const total = parts.length + const dominated = counts[action] / total >= 0.6 + + // thought group: sum durations → "Thought for Xm Ys" + if (dominated && action === "thought") { + const dur = thoughtGroupDuration(parts) + return dur ? `Thought for ${dur}` : `Thought about ${counts.thought} topics` + } + + // read group: list up to 3 filenames → "Read PLAN.md and index.html" + if (dominated && action === "read") { + return readGroupLabel(parts) + } + + // created group: pick "Edited" vs "Created" based on majority, list filenames + if (dominated && action === "created") { + return createdGroupLabel(parts) + } + + if (dominated) return `${ACTION_WORD[action]} ${counts[action]} ${GROUP_SUBJECT[action]}` + const sorted = (Object.entries(counts) as [BlurbAction, number][]) + .filter(([, n]) => n > 0) + .sort(([, a], [, b]) => b - a) + if (sorted.length === 1) return `${ACTION_WORD[sorted[0][0]]} ${sorted[0][1]} ${GROUP_SUBJECT[sorted[0][0]]}` + return `${total} actions` +} + +function thoughtGroupDuration(parts: Part[]): string | null { + let minStart: number | null = null + let maxEnd: number | null = null + for (const p of parts) { + if (p.type !== "reasoning") continue + if (minStart === null || p.time.start < minStart) minStart = p.time.start + const end = p.time.end ?? null + if (end !== null && (maxEnd === null || end > maxEnd)) maxEnd = end + } + if (minStart === null) return null + return formatThoughtDuration(minStart, maxEnd ?? undefined) +} + +function readGroupLabel(parts: Part[]): string { + const names: string[] = [] + for (const p of parts) { + if (p.type !== "tool") continue + const title = "title" in p.state && typeof p.state.title === "string" ? p.state.title : "" + const name = fileBasename(title || p.tool) + if (name && !names.includes(name)) names.push(name) + if (names.length === 3) break + } + if (names.length === 0) return `Read ${parts.length} files` + if (names.length === 1) return `Read ${names[0]}` + if (names.length === 2) return `Read ${names[0]} and ${names[1]}` + return `Read ${names[0]}, ${names[1]} and ${names[2]}` +} + +function createdGroupLabel(parts: Part[]): string { + let edits = 0 + let creates = 0 + const names: string[] = [] + for (const p of parts) { + if (p.type !== "tool") continue + if (isEditTool(p.tool)) edits++ + else creates++ + const title = "title" in p.state && typeof p.state.title === "string" ? p.state.title : "" + const name = fileBasename(title || p.tool) + if (name && !names.includes(name)) names.push(name) + } + const verb = edits >= creates ? "Edited" : "Created" + if (names.length === 0) return `${verb} ${parts.length} files` + if (names.length === 1) return `${verb} ${names[0]}` + if (names.length === 2) return `${verb} ${names[0]} and ${names[1]}` + if (names.length === 3) return `${verb} ${names[0]}, ${names[1]} and ${names[2]}` + return `${verb} ${names.length} files` +} + export function isTodoToolPart(part: Part | ToolPart): part is ToolPart { return part.type === "tool" && (part.tool === "todowrite" || part.tool === "todoread") } @@ -977,14 +1376,6 @@ export function resolveLatestTodoSnapshot(parts: Part[]) { return null } -function todoStatusLabel(status: string) { - if (status === "in_progress") return "ACTIVE" - if (status === "pending") return "PENDING" - if (status === "completed") return "DONE" - if (status === "cancelled") return "CANCELLED" - return status.toUpperCase() -} - function todoStatusIcon(status: string) { if (status === "in_progress") return "play-circle" if (status === "completed") return "check-circle" @@ -1003,7 +1394,7 @@ function todoStatusColor(status: string, colors: Palette) { function blurbActionColor(action: BlurbAction, colors: Palette) { if (action === "created") return colors.statusIdle if (action === "ran") return colors.accent - if (action === "explored") return colors.warning + if (action === "read") return colors.warning return colors.textSecondary } @@ -1039,7 +1430,9 @@ function toolCommand(part: ToolPart) { if (typeof description === "string" && description.trim()) return `task ${description.trim()}` } - const firstString = Object.values(input).find((value): value is string => typeof value === "string" && value.trim().length > 0) + const firstString = Object.values(input).find( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) if (firstString) return summarizeInline(firstString.trim(), 160) return "" } @@ -1052,7 +1445,9 @@ function toolInputPreview(part: ToolPart) { .find((value): value is string => typeof value === "string" && value.trim().length > 0) if (direct) return direct.trim() - const first = Object.values(input).find((value): value is string => typeof value === "string" && value.trim().length > 0) + const first = Object.values(input).find( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) if (first) return first.trim() const keys = Object.keys(input) @@ -1074,8 +1469,15 @@ function parseReadOutput(output: string) { return { path, type, content } } +const tagPatterns = new Map() + function extractTag(value: string, tag: string) { - const match = value.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)) + let pattern = tagPatterns.get(tag) + if (!pattern) { + pattern = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`) + tagPatterns.set(tag, pattern) + } + const match = value.match(pattern) return match?.[1]?.trim() ?? "" } @@ -1093,7 +1495,13 @@ function stripReadLineNumbers(content: string) { function languageFromPath(filePath: string) { const normalized = filePath.toLowerCase() if (normalized.endsWith(".tsx") || normalized.endsWith(".ts")) return "ts" - if (normalized.endsWith(".jsx") || normalized.endsWith(".js") || normalized.endsWith(".mjs") || normalized.endsWith(".cjs")) return "js" + if ( + normalized.endsWith(".jsx") || + normalized.endsWith(".js") || + normalized.endsWith(".mjs") || + normalized.endsWith(".cjs") + ) + return "js" if (normalized.endsWith(".py")) return "py" if (normalized.endsWith(".sh") || normalized.endsWith(".bash") || normalized.endsWith(".zsh")) return "sh" if (normalized.endsWith(".json")) return "json" @@ -1222,6 +1630,14 @@ const styles = StyleSheet.create({ reasoningExpanded: { paddingTop: 2, }, + reasoningShowMore: { + paddingTop: 4, + paddingBottom: 2, + }, + reasoningShowMoreText: { + fontSize: 12, + fontWeight: "500", + }, infoLink: { fontSize: 12, fontWeight: "600", @@ -1278,6 +1694,9 @@ const styles = StyleSheet.create({ todoList: { gap: 6, }, + todoScroll: { + maxHeight: 160, + }, todoItem: { flexDirection: "row", alignItems: "center", @@ -1293,12 +1712,6 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 17, }, - todoState: { - fontSize: 10, - lineHeight: 12, - fontWeight: "700", - letterSpacing: 0.3, - }, todoEmpty: { fontSize: 12, lineHeight: 16, @@ -1376,4 +1789,7 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: "500", }, + nestedGroup: { + paddingLeft: 20, + }, }) diff --git a/packages/mobile/src/components/chat/provider.tsx b/packages/mobile/src/components/chat/provider.tsx index b8f02af21c35..24221eff276c 100644 --- a/packages/mobile/src/components/chat/provider.tsx +++ b/packages/mobile/src/components/chat/provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useRef, useState, useCallback, type ReactNode } from "react" +import { createContext, useContext, useRef, useState, useCallback, useMemo, type ReactNode } from "react" import { useSharedValue, type SharedValue } from "react-native-reanimated" import type { Message } from "@opencode-ai/sdk/client" import type { FlashListRef } from "@shopify/flash-list" @@ -21,30 +21,25 @@ export function ChatProvider({ children }: { children: ReactNode }) { const messageCount = useSharedValue(0) const listRef = useRef>(null) - const setComposerH = useCallback((h: number) => { - const next = Math.max(0, Math.round(h)) - setComposerHState((current) => { - // Ignore tiny layout jitter during keyboard transitions to prevent list inset flicker. - if (Math.abs(current - next) <= 1) return current - composerHeight.value = next - return next - }) - }, [composerHeight]) + const setComposerH = useCallback( + (h: number) => { + const next = Math.max(0, Math.round(h)) + setComposerHState((current) => { + // Ignore tiny layout jitter during keyboard transitions to prevent list inset flicker. + if (Math.abs(current - next) <= 1) return current + composerHeight.value = next + return next + }) + }, + [composerHeight], + ) - return ( - - {children} - + const value = useMemo( + () => ({ composerHeight, composerH, setComposerH, listRef, isAtEnd, messageCount }), + [composerH, composerHeight, isAtEnd, listRef, messageCount, setComposerH], ) + + return {children} } export function useChat(): ChatContextValue { diff --git a/packages/mobile/src/components/chat/turn-diff-indicator.tsx b/packages/mobile/src/components/chat/turn-diff-indicator.tsx new file mode 100644 index 000000000000..cafe3089e06f --- /dev/null +++ b/packages/mobile/src/components/chat/turn-diff-indicator.tsx @@ -0,0 +1,98 @@ +import { memo, useMemo } from "react" +import { Pressable, StyleSheet, Text, View } from "react-native" +import type { SessionFileDiff } from "../../features/diff/types" +import { useTheme } from "../../theme" + +type Props = { + diffs: SessionFileDiff[] + onPress: () => void +} + +function basename(path: string) { + const parts = path.split(/[\\/]/) + return parts[parts.length - 1] || path +} + +function parentDir(path: string) { + const parts = path.split(/[\\/]/) + if (parts.length <= 1) return "" + return parts[parts.length - 2] || "" +} + +export const TurnDiffIndicator = memo(function TurnDiffIndicator({ diffs, onPress }: Props) { + const theme = useTheme() + const files = useMemo(() => diffs.filter((d) => !!d.file), [diffs]) + + if (files.length === 0) return null + + return ( + [ + styles.container, + { backgroundColor: theme.colors.surfaceRaised, borderColor: theme.colors.border }, + pressed && styles.pressed, + ]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={`${files.length} file${files.length === 1 ? "" : "s"} changed`} + > + {files.map((diff) => { + const hasAdd = diff.additions > 0 + const hasDel = diff.deletions > 0 + const dir = parentDir(diff.file) + const name = basename(diff.file) + return ( + + + {hasAdd ? : null} + {hasDel ? : null} + {!hasAdd && !hasDel ? ( + + ) : null} + + + {dir ? {dir}/ : null} + {name} + + + ) + })} + + ) +}) + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + gap: 6, + marginTop: 8, + }, + pressed: { + opacity: 0.7, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingVertical: 2, + }, + dots: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + dot: { + width: 7, + height: 7, + borderRadius: 4, + }, + filename: { + fontSize: 13, + lineHeight: 18, + fontWeight: "500", + flex: 1, + }, +}) diff --git a/packages/mobile/src/components/chat/user-message.tsx b/packages/mobile/src/components/chat/user-message.tsx index 8c1b004bdf49..4ff83716eae4 100644 --- a/packages/mobile/src/components/chat/user-message.tsx +++ b/packages/mobile/src/components/chat/user-message.tsx @@ -1,4 +1,4 @@ -import { memo } from "react" +import { memo, useMemo } from "react" import { View, Text, StyleSheet } from "react-native" import type { Message, Part } from "@opencode-ai/sdk/client" import { useMessageParts } from "../../api/hooks" @@ -13,11 +13,15 @@ export const UserMessage = memo(function UserMessage({ message }: Props) { const theme = useTheme() const parts = useMessageParts(message.id) - const text = parts - .filter((part): part is Extract => part.type === "text") - .map((part) => part.text) - .join("") - const summary = parts.map(partSummary).filter(Boolean).join("\n") + const text = useMemo( + () => + parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join(""), + [parts], + ) + const summary = useMemo(() => parts.map(partSummary).filter(Boolean).join("\n"), [parts]) return ( diff --git a/packages/mobile/src/components/markdown/code-block.tsx b/packages/mobile/src/components/markdown/code-block.tsx index 6c940ff2f967..d286bd4b5efb 100644 --- a/packages/mobile/src/components/markdown/code-block.tsx +++ b/packages/mobile/src/components/markdown/code-block.tsx @@ -1,8 +1,10 @@ -import { memo, useCallback, useMemo } from "react" +import { memo, useCallback, useMemo, useState } from "react" import { View, Text, Pressable, ScrollView, StyleSheet } from "react-native" import * as Clipboard from "expo-clipboard" import { useTheme } from "../../theme" +const VISIBLE_LINE_CAP = 30 + type Props = { code: string language?: string @@ -10,13 +12,18 @@ type Props = { export const CodeBlock = memo(function CodeBlock({ code, language }: Props) { const theme = useTheme() + const [expanded, setExpanded] = useState(false) const copy = useCallback(() => { void Clipboard.setStringAsync(code) }, [code]) const normalized = normalizeLanguage(language) - const highlighted = useMemo(() => cachedHighlight(code, normalized), [code, normalized]) + const lines = useMemo(() => code.split("\n"), [code]) + const capped = !expanded && lines.length > VISIBLE_LINE_CAP + const visible = capped ? lines.slice(0, VISIBLE_LINE_CAP).join("\n") : code + const highlighted = useMemo(() => cachedHighlight(visible, normalized), [visible, normalized]) + const hiddenCount = capped ? lines.length - VISIBLE_LINE_CAP : 0 return ( @@ -44,6 +51,16 @@ export const CodeBlock = memo(function CodeBlock({ code, language }: Props) { })} + {capped ? ( + setExpanded(true)} + style={[styles.showMore, { borderTopColor: theme.colors.codeBorder }]} + > + + Show {hiddenCount} more line{hiddenCount === 1 ? "" : "s"} + + + ) : null} ) }) @@ -285,4 +302,14 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 19, }, + showMore: { + borderTopWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 12, + paddingVertical: 8, + alignItems: "center", + }, + showMoreText: { + fontSize: 12, + fontWeight: "500", + }, }) diff --git a/packages/mobile/src/components/markdown/renderer.tsx b/packages/mobile/src/components/markdown/renderer.tsx index 7be8d138114a..6ea5feac1bdf 100644 --- a/packages/mobile/src/components/markdown/renderer.tsx +++ b/packages/mobile/src/components/markdown/renderer.tsx @@ -4,6 +4,7 @@ import MarkdownBase from "react-native-markdown-display" import { StreamdownRN } from "streamdown-rn" import { useTheme, type Theme } from "../../theme" import { FEATURE_FLAGS } from "../../config/feature-flags" +import { telemetry } from "../../perf/telemetry" import { CodeBlock } from "./code-block" type Props = { @@ -51,6 +52,7 @@ class MarkdownErrorBoundary extends React.Component 0 && !input.startsWith(prevTarget) && !input.startsWith(displayedRef.current) + const nonAppendReset = + prevTarget.length > 0 && !input.startsWith(prevTarget) && !input.startsWith(displayedRef.current) if (nonAppendReset || isComplete || input.length > TYPED_STREAM_MAX_CHARS) { stop() if (displayedRef.current !== input) syncDisplayed(input) @@ -239,15 +242,7 @@ function rules(theme: Theme) { code_inline: (node: { key: string; content?: string }) => ( {node.content} @@ -255,7 +250,7 @@ function rules(theme: Theme) { link: (node: { key: string; attributes?: { href?: string } }, children: React.ReactNode) => ( { if (!node.attributes?.href) return Linking.openURL(node.attributes.href).catch(() => { @@ -271,9 +266,7 @@ function rules(theme: Theme) { function stylesForVariant(theme: Theme, variant: "default" | "reasoning"): Record { const isReasoning = variant === "reasoning" - const baseText: TextStyle = isReasoning - ? { fontFamily: "Geist", fontStyle: "italic" } - : { fontFamily: "Geist" } + const baseText: TextStyle = isReasoning ? { fontFamily: "Geist", fontStyle: "italic" } : { fontFamily: "Geist" } const bodySize = isReasoning ? 13 : 15 const bodyLine = isReasoning ? 18 : 23 const heading1Size = isReasoning ? 16 : 22 @@ -284,10 +277,38 @@ function stylesForVariant(theme: Theme, variant: "default" | "reasoning"): Recor return { body: { ...baseText, color: theme.colors.text, fontSize: bodySize, lineHeight: bodyLine }, paragraph: { ...baseText, marginTop: 0, marginBottom: isReasoning ? 6 : 6 }, - heading1: { ...baseText, fontSize: heading1Size, fontWeight: "700" as const, marginBottom: 8, marginTop: 16, color: theme.colors.text }, - heading2: { ...baseText, fontSize: heading2Size, fontWeight: "700" as const, marginBottom: 6, marginTop: 14, color: theme.colors.text }, - heading3: { ...baseText, fontSize: heading3Size, fontWeight: "600" as const, marginBottom: 4, marginTop: 12, color: theme.colors.text }, - heading4: { ...baseText, fontSize: heading4Size, fontWeight: "600" as const, marginBottom: 4, marginTop: 10, color: theme.colors.text }, + heading1: { + ...baseText, + fontSize: heading1Size, + fontWeight: "700" as const, + marginBottom: 8, + marginTop: 16, + color: theme.colors.text, + }, + heading2: { + ...baseText, + fontSize: heading2Size, + fontWeight: "700" as const, + marginBottom: 6, + marginTop: 14, + color: theme.colors.text, + }, + heading3: { + ...baseText, + fontSize: heading3Size, + fontWeight: "600" as const, + marginBottom: 4, + marginTop: 12, + color: theme.colors.text, + }, + heading4: { + ...baseText, + fontSize: heading4Size, + fontWeight: "600" as const, + marginBottom: 4, + marginTop: 10, + color: theme.colors.text, + }, blockquote: { borderLeftWidth: 3, borderLeftColor: theme.colors.border, @@ -319,7 +340,13 @@ function stylesForVariant(theme: Theme, variant: "default" | "reasoning"): Recor } } -const LegacyMarkdown = memo(function LegacyMarkdown({ text, variant }: { text: string; variant: "default" | "reasoning" }) { +const LegacyMarkdown = memo(function LegacyMarkdown({ + text, + variant, +}: { + text: string + variant: "default" | "reasoning" +}) { const theme = useTheme() const mdRules = useMemo(() => rules(theme), [theme]) const mdStyles = useMemo(() => stylesForVariant(theme, variant), [theme, variant]) @@ -374,10 +401,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ [theme.colors, variant], ) - if (!children) return null - - const shouldTypeAnimate = allowTypedStreaming(children, variant, isComplete) - const streamingText = useTypedStreamingText(children, { + const shouldTypeAnimate = children ? allowTypedStreaming(children, variant, isComplete) : false + const streamingText = useTypedStreamingText(children || "", { enabled: shouldTypeAnimate, isComplete, }) @@ -391,6 +416,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ } }, [children]) + if (!children) return null + const fallback = return ( @@ -405,6 +432,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ style={styles.markdown} onError={(error) => { setUseLegacyRenderer(true) + telemetry.error("ui", "markdown:fallback", { message: error.message }) if (__DEV__) { console.warn("[markdown] stream renderer error", error) } @@ -430,4 +458,14 @@ const styles = StyleSheet.create({ reasoning: { opacity: 0.9, }, + codeInline: { + fontFamily: "Geist Mono", + fontSize: 13, + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: 4, + }, + linkText: { + textDecorationLine: "underline" as const, + }, }) diff --git a/packages/mobile/src/components/session-diff-panel.tsx b/packages/mobile/src/components/session-diff-panel.tsx new file mode 100644 index 000000000000..bb7837c7d1ef --- /dev/null +++ b/packages/mobile/src/components/session-diff-panel.tsx @@ -0,0 +1,667 @@ +import { Component, useCallback, useEffect, useMemo, useRef, useState, memo } from "react" +import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native" +import { FlashList } from "@shopify/flash-list" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import Feather from "@expo/vector-icons/Feather" +import { useTheme } from "../theme" +import { useSessions } from "../store/sessions" +import { computeSummary, useDiffs } from "../store/diffs" +import { parseUnifiedDiffRows } from "../features/diff/parse" +import { normalizeDiffList, dedupeDiffs } from "../features/diff/normalize" +import type { DiffLine, SessionFileDiff } from "../features/diff/types" +import { useSessionDiffSummary } from "../api/hooks" +import { telemetry } from "../perf/telemetry" + +const FeatherIcon = Feather as unknown as React.ComponentType<{ name: string; size: number; color: string }> + +const EMPTY_DIFFS: SessionFileDiff[] = [] +const EMPTY_LINES: DiffLine[] = [] +const INITIAL_LINE_CAP = 800 +const LOAD_MORE_STEP = 400 +const MAX_RENDERABLE_DIFF_CHARS = 200_000 +const PARSED_ROWS_CACHE_MAX = 180 + +type ExpandedMap = Record +type LineCapMap = Record + +function asNumber(value: unknown) { + return Number.isFinite(value) ? Number(value) : 0 +} + +function statusOf(diff: SessionFileDiff) { + if (diff.status) return diff.status + const hasBefore = !!diff.before + const hasAfter = !!diff.after + if (!hasBefore && hasAfter) return "added" + if (hasBefore && !hasAfter) return "deleted" + if (diff.additions > 0 || diff.deletions > 0 || diff.before !== diff.after) return "modified" + return "unchanged" +} + +function safeParseRows(diff: SessionFileDiff): DiffLine[] { + const before = typeof diff.before === "string" ? diff.before : "" + const after = typeof diff.after === "string" ? diff.after : "" + if (before.length + after.length > MAX_RENDERABLE_DIFF_CHARS) { + return [{ type: "meta", leftLineNo: null, rightLineNo: null, text: "Diff too large to render on mobile." }] + } + try { + return parseUnifiedDiffRows({ ...diff, before, after }) + } catch { + return [{ type: "meta", leftLineNo: null, rightLineNo: null, text: "Unable to render diff for this file." }] + } +} + +function hashText(value: string) { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(36) +} + +function diffCacheKey(diff: SessionFileDiff) { + return `${diff.file}\u0000${hashText(diff.before ?? "")}\u0000${hashText(diff.after ?? "")}` +} + +function diffKeyExtractor(item: SessionFileDiff) { + return item.file +} + +type Props = { + sessionId: string | null +} + +class DiffPanelBoundary extends Component<{ children: React.ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch(error: Error) { + console.error("[SessionDiffPanel] crash:", error) + } + + render() { + if (this.state.error) { + return ( + + Diff panel crashed + + {this.state.error.message} + + + {this.state.error.stack?.slice(0, 800)} + + + ) + } + return this.props.children + } +} + +export function SessionDiffPanel(props: Props) { + return ( + + + + ) +} + +function SessionDiffPanelInner({ sessionId }: Props) { + const insets = useSafeAreaInsets() + const theme = useTheme() + const [query, setQuery] = useState("") + const [expanded, setExpanded] = useState({}) + const [lineCap, setLineCap] = useState({}) + const parsedRowsCacheRef = useRef>(new Map()) + + const session = useSessions((s) => (sessionId ? s.sessions.find((item) => item.id === sessionId) : undefined)) + const diffs = useDiffs((s) => (sessionId ? (s.bySession[sessionId] ?? EMPTY_DIFFS) : EMPTY_DIFFS)) + const loading = useDiffs((s) => (sessionId ? (s.loading[sessionId] ?? false) : false)) + const fetchSessionDiff = useDiffs((s) => s.fetchSessionDiff) + const diffSummary = useSessionDiffSummary(sessionId ?? "", { includeSynthetic: true }) + + useEffect(() => { + if (!sessionId) return + telemetry.track("diff", "diff:panel:open", { sessionId }) + void fetchSessionDiff(sessionId) + }, [fetchSessionDiff, sessionId]) + + const apiDiffs = useMemo(() => normalizeDiffList(diffs, EMPTY_DIFFS), [diffs]) + + const merged = useMemo( + () => dedupeDiffs([...apiDiffs, ...diffSummary.historyDiffs, ...diffSummary.syntheticDiffs]), + [apiDiffs, diffSummary.historyDiffs, diffSummary.syntheticDiffs], + ) + + const summaryFromDiffs = useMemo(() => computeSummary(merged), [merged]) + const summary = useMemo( + () => ({ + files: Math.max(summaryFromDiffs.files, Math.max(0, asNumber(session?.summary?.files))), + additions: Math.max(summaryFromDiffs.additions, Math.max(0, asNumber(session?.summary?.additions))), + deletions: Math.max(summaryFromDiffs.deletions, Math.max(0, asNumber(session?.summary?.deletions))), + }), + [summaryFromDiffs, session?.summary?.files, session?.summary?.additions, session?.summary?.deletions], + ) + + const normalizedQuery = query.trim().toLowerCase() + const filteredDiffs = useMemo(() => { + if (!normalizedQuery) return merged + return merged.filter((item) => item.file.toLowerCase().includes(normalizedQuery)) + }, [merged, normalizedQuery]) + + useEffect(() => { + const validKeys = new Set(merged.map(diffCacheKey)) + const cache = parsedRowsCacheRef.current + for (const key of [...cache.keys()]) { + if (!validKeys.has(key)) cache.delete(key) + } + }, [merged]) + + // Reset state when session changes + useEffect(() => { + setQuery("") + setExpanded({}) + setLineCap({}) + parsedRowsCacheRef.current.clear() + }, [sessionId]) + + const getParsedRows = useCallback((item: SessionFileDiff) => { + const key = diffCacheKey(item) + const cache = parsedRowsCacheRef.current + const cached = cache.get(key) + if (cached) { + cache.delete(key) + cache.set(key, cached) + return cached + } + const parsed = safeParseRows(item) + cache.set(key, parsed) + while (cache.size > PARSED_ROWS_CACHE_MAX) { + const oldest = cache.keys().next().value + if (!oldest) break + cache.delete(oldest) + } + return parsed + }, []) + + const onRefresh = useCallback(() => { + if (!sessionId) return + telemetry.track("diff", "diff:refresh", { sessionId }) + void fetchSessionDiff(sessionId, { force: true }) + }, [fetchSessionDiff, sessionId]) + + const toggleFile = useCallback((file: string) => { + setExpanded((prev) => { + const opening = !prev[file] + telemetry.track("diff", "diff:file:toggle", { file, opening }) + if (opening) { + setLineCap((cap) => (cap[file] ? cap : { ...cap, [file]: INITIAL_LINE_CAP })) + } + return { ...prev, [file]: opening } + }) + }, []) + + const loadMore = useCallback((file: string) => { + setLineCap((prev) => ({ + ...prev, + [file]: Math.max(INITIAL_LINE_CAP, (prev[file] ?? INITIAL_LINE_CAP) + LOAD_MORE_STEP), + })) + }, []) + + const renderFile = useCallback( + ({ item }: { item: SessionFileDiff }) => { + const isExpanded = !!expanded[item.file] + const parsedRows = isExpanded ? getParsedRows(item) : EMPTY_LINES + const cap = lineCap[item.file] ?? INITIAL_LINE_CAP + const visibleRows = isExpanded ? parsedRows.slice(0, cap) : EMPTY_LINES + const remaining = Math.max(0, parsedRows.length - cap) + + return ( + + toggleFile(item.file)}> + + + {item.file} + + + + +{item.additions} + -{item.deletions} + + + + + {isExpanded ? ( + + {visibleRows.map((line, index) => ( + + ))} + {remaining > 0 ? ( + [ + styles.loadMoreBtn, + pressed && styles.pressed, + { borderColor: theme.colors.border, backgroundColor: theme.colors.surfaceRaised }, + ]} + onPress={() => loadMore(item.file)} + > + + Load more lines ({remaining}) + + + ) : null} + + ) : null} + + ) + }, + [ + expanded, + getParsedRows, + lineCap, + loadMore, + theme.colors.border, + theme.colors.error, + theme.colors.success, + theme.colors.surface, + theme.colors.text, + theme.colors.textTertiary, + theme.colors.borderSubtle, + theme.colors.surfaceRaised, + theme.colors.textSecondary, + toggleFile, + ], + ) + + const hasNoChanges = merged.length === 0 && !loading + + return ( + + + + {session?.title?.trim() || "Session changes"} + + {!hasNoChanges ? ( + + + + + + ) : null} + + + {hasNoChanges ? ( + + + No changes yet + + File changes will appear here as the session progresses. + + + ) : ( + + + + + + {filteredDiffs.length === 0 && normalizedQuery ? ( + + + No files match your search. + + + ) : null} + + } + ListFooterComponent={} + refreshing={loading} + onRefresh={onRefresh} + keyboardShouldPersistTaps="handled" + contentContainerStyle={styles.listContent} + /> + )} + + ) +} + +const StatusBadge = memo(function StatusBadge({ + status, + successColor, + errorColor, + tertiaryColor, +}: { + status: string + successColor: string + errorColor: string + tertiaryColor: string +}) { + const color = status === "added" ? successColor : status === "deleted" ? errorColor : tertiaryColor + return ( + + {status} + + ) +}) + +const SummaryChip = memo(function SummaryChip({ + label, + value, + valueColor, + borderColor, + backgroundColor, + textColor, + tertiaryColor, +}: { + label: string + value: string + valueColor?: string + borderColor: string + backgroundColor: string + textColor: string + tertiaryColor: string +}) { + return ( + + {value} + {label} + + ) +}) + +const DiffLineRow = memo(function DiffLineRow({ + line, + successColor, + errorColor, + tertiaryColor, + textColor, +}: { + line: DiffLine + successColor: string + errorColor: string + tertiaryColor: string + textColor: string +}) { + const rowStyle = [ + styles.row, + line.type === "added" + ? styles.addedRow + : line.type === "removed" + ? styles.removedRow + : line.type === "meta" + ? styles.metaRow + : null, + ] + const color = + line.type === "added" + ? successColor + : line.type === "removed" + ? errorColor + : line.type === "meta" + ? tertiaryColor + : textColor + + return ( + + {line.leftLineNo ?? ""} + {line.rightLineNo ?? ""} + {line.text || " "} + + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + borderBottomWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 16, + paddingBottom: 10, + gap: 10, + }, + title: { + fontSize: 16, + fontWeight: "600", + }, + chips: { + flexDirection: "row", + gap: 8, + }, + summaryChip: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + minWidth: 80, + }, + summaryValue: { + fontSize: 14, + fontWeight: "700", + }, + summaryLabel: { + fontSize: 11, + textTransform: "uppercase", + marginTop: 1, + }, + listContent: { + paddingHorizontal: 12, + paddingTop: 12, + }, + listHeader: { + marginBottom: 12, + gap: 12, + }, + searchWrap: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 10, + minHeight: 42, + paddingHorizontal: 10, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + searchInput: { + flex: 1, + fontSize: 14, + paddingVertical: Platform.OS === "ios" ? 9 : 7, + }, + fileCard: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 12, + marginBottom: 10, + overflow: "hidden", + }, + fileHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + paddingHorizontal: 12, + paddingVertical: 11, + }, + fileHeadMain: { + flex: 1, + gap: 8, + }, + filePath: { + fontSize: 13, + fontWeight: "600", + }, + fileMeta: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + statusBadge: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + statusText: { + fontSize: 10, + textTransform: "uppercase", + fontWeight: "600", + }, + fileCountPlus: { + fontSize: 12, + fontWeight: "600", + }, + fileCountMinus: { + fontSize: 12, + fontWeight: "600", + }, + rowsWrap: { + borderTopWidth: StyleSheet.hairlineWidth, + }, + row: { + flexDirection: "row", + alignItems: "flex-start", + gap: 6, + paddingHorizontal: 10, + paddingVertical: 3, + }, + addedRow: { + backgroundColor: "rgba(16, 185, 129, 0.11)", + }, + removedRow: { + backgroundColor: "rgba(239, 68, 68, 0.11)", + }, + metaRow: { + backgroundColor: "rgba(148, 163, 184, 0.12)", + }, + lineNo: { + width: 34, + fontSize: 11, + textAlign: "right", + fontFamily: Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }), + }, + lineText: { + flex: 1, + fontSize: 12, + lineHeight: 18, + fontFamily: Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }), + }, + loadMoreBtn: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + margin: 10, + alignItems: "center", + justifyContent: "center", + paddingVertical: 8, + }, + loadMoreText: { + fontSize: 12, + fontWeight: "600", + }, + pressed: { + opacity: 0.65, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + gap: 8, + }, + emptyTitle: { + fontSize: 16, + fontWeight: "600", + marginTop: 4, + }, + emptySubtitle: { + fontSize: 13, + textAlign: "center", + lineHeight: 18, + }, + emptySearch: { + alignItems: "center", + paddingVertical: 24, + }, + emptySearchText: { + fontSize: 14, + textAlign: "center", + }, +}) diff --git a/packages/mobile/src/components/sidebar/index.tsx b/packages/mobile/src/components/sidebar/index.tsx index e430f5b24b25..831f8dfbea6a 100644 --- a/packages/mobile/src/components/sidebar/index.tsx +++ b/packages/mobile/src/components/sidebar/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, memo, useState } from "react" +import { useCallback, useMemo, memo, useState } from "react" import { View, Text, TextInput, Pressable, StyleSheet, RefreshControl, Alert } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { LiquidGlassView, isLiquidGlassSupported } from "@callstack/liquid-glass" @@ -14,7 +14,6 @@ import { useConnection } from "../../store/connection" import { useTheme } from "../../theme" import { relative } from "../../util/format" import { SessionListSkeleton } from "../skeleton" -import { AnimatedStatusDot } from "../status-dot" import { client } from "../../api/client" import { ServerSwitcher } from "./server-switcher" @@ -46,7 +45,8 @@ type Props = { onServerSwitched?: () => void } -type SectionItem = { +type ProjectHeaderItem = { + type: "header" project: Project sessions: Session[] count: number @@ -54,6 +54,22 @@ type SectionItem = { active: boolean } +type SessionRowItem = { + type: "session" + session: Session + isLast: boolean + worktree: string +} + +type LoadMoreItem = { + type: "load-more" + worktree: string + remaining: number + chunk: number +} + +type FlatItem = ProjectHeaderItem | SessionRowItem | LoadMoreItem + function resolveRouteSessionID(value: string | string[] | undefined) { if (typeof value === "string") return value || null if (Array.isArray(value)) { @@ -64,13 +80,7 @@ function resolveRouteSessionID(value: string | string[] | undefined) { return null } -export const Sidebar = memo(function Sidebar({ - onSelect, - onNew, - onSettings, - sidebarVisible, - onServerSwitched, -}: Props) { +export const Sidebar = memo(function Sidebar({ onSelect, onNew, onSettings, sidebarVisible, onServerSwitched }: Props) { const theme = useTheme() const insets = useSafeAreaInsets() const projects = useSessions((s) => s.projects) @@ -85,6 +95,7 @@ export const Sidebar = memo(function Sidebar({ const routeParams = useGlobalSearchParams<{ id?: string | string[] }>() const [query, setQuery] = useState("") const [collapsed, setCollapsed] = useState>({}) + const [visibleCounts, setVisibleCounts] = useState>({}) const routeSessionID = useMemo(() => resolveRouteSessionID(routeParams.id), [routeParams.id]) const activeSessionID = routeSessionID ?? currentSessionID @@ -131,19 +142,43 @@ export const Sidebar = memo(function Sidebar({ return bTime - aTime }) - return orderedProjects.map((project) => { + const flat: FlatItem[] = [] + for (const project of orderedProjects) { const sessions = grouped[project.worktree] ?? [] const isCollapsed = collapsed[project.worktree] ?? false - const hasActiveSession = !!activeSessionID && sessions.some((session) => session.id === activeSessionID) - return { + const hasActiveSession = !!activeSessionID && sessions.some((s) => s.id === activeSessionID) + flat.push({ + type: "header", project, sessions, count: sessions.length, collapsed: isCollapsed, active: hasActiveSession || project.worktree === directory, + }) + if (!isCollapsed && sessions.length > 0) { + const cap = Math.max(SESSION_RENDER_CHUNK, visibleCounts[project.worktree] ?? SESSION_RENDER_CHUNK) + const visible = sessions.slice(0, cap) + const remaining = Math.max(0, sessions.length - visible.length) + for (let i = 0; i < visible.length; i++) { + flat.push({ + type: "session", + session: visible[i], + isLast: i === visible.length - 1 && remaining === 0, + worktree: project.worktree, + }) + } + if (remaining > 0) { + flat.push({ + type: "load-more", + worktree: project.worktree, + remaining, + chunk: Math.min(remaining, SESSION_RENDER_CHUNK), + }) + } } - }) - }, [activeSessionID, collapsed, directory, filteredSessions, projectsWithFallback]) + } + return flat + }, [activeSessionID, collapsed, directory, filteredSessions, projectsWithFallback, visibleCounts]) const handleArchive = useCallback( (id: string) => { @@ -192,7 +227,17 @@ export const Sidebar = memo(function Sidebar({ const toggleProject = useCallback((worktree: string) => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) - setCollapsed((state) => ({ ...state, [worktree]: !(state[worktree] ?? false) })) + setCollapsed((state) => { + const next = !(state[worktree] ?? false) + if (next) { + setVisibleCounts((counts) => { + if (!(worktree in counts)) return counts + const { [worktree]: _, ...rest } = counts + return rest + }) + } + return { ...state, [worktree]: next } + }) }, []) const allCollapsed = useMemo(() => { @@ -220,42 +265,130 @@ export const Sidebar = memo(function Sidebar({ [onNew], ) + const loadMoreSessions = useCallback((worktree: string) => { + setVisibleCounts((counts) => ({ + ...counts, + [worktree]: (counts[worktree] ?? SESSION_RENDER_CHUNK) + SESSION_RENDER_CHUNK, + })) + }, []) + + const projectRowColors = useMemo( + () => ({ + border: theme.colors.border, + surface: theme.colors.surface, + text: theme.colors.text, + textSecondary: theme.colors.textSecondary, + textTertiary: theme.colors.textTertiary, + surfaceRaised: theme.colors.surfaceRaised, + }), + [ + theme.colors.border, + theme.colors.surface, + theme.colors.text, + theme.colors.textSecondary, + theme.colors.textTertiary, + theme.colors.surfaceRaised, + ], + ) + + const projectRowRadii = useMemo(() => ({ md: theme.radii.md }), [theme.radii.md]) + const renderItem = useCallback( - ({ item }: ListRenderItemInfo) => { + ({ item }: ListRenderItemInfo) => { + if (item.type === "header") { + return ( + + ) + } + if (item.type === "session") { + return ( + void onSelect(session)} + onArchive={handleArchive} + onDelete={handleDelete} + onShare={handleShare} + activeSessionID={activeSessionID} + textColor={theme.colors.text} + tertiaryColor={theme.colors.textTertiary} + surfaceColor={theme.colors.surface} + /> + ) + } return ( - void onSelect(session)} - onArchive={handleArchive} - onDelete={handleDelete} - onShare={handleShare} - activeSessionID={activeSessionID} + ) }, - [activeSessionID, handleArchive, handleCreateSession, handleDelete, handleShare, onSelect, toggleProject], + [ + activeSessionID, + handleArchive, + handleCreateSession, + handleDelete, + handleShare, + loadMoreSessions, + onSelect, + projectRowColors, + projectRowRadii, + theme.colors.border, + theme.colors.surface, + theme.colors.text, + theme.colors.textSecondary, + theme.colors.textTertiary, + toggleProject, + ], ) - const keyExtractor = useCallback((item: SectionItem) => { - return `project-${item.project.id}-${item.project.worktree}` + const keyExtractor = useCallback((item: FlatItem) => { + if (item.type === "header") return `project-${item.project.id}-${item.project.worktree}` + if (item.type === "session") return `session-${item.session.id}` + return `load-more-${item.worktree}` }, []) - const getItemType = useCallback(() => "project", []) + const getItemType = useCallback((item: FlatItem) => item.type, []) const content = ( Threads - handleCreateSession()} /> + handleCreateSession()} + textSecondaryColor={theme.colors.textSecondary} + surfaceColor={theme.colors.surface} + borderColor={theme.colors.border} + /> + - @@ -343,12 +476,22 @@ const ProjectRow = memo(function ProjectRow({ item, onToggle, onNew, + colors, + radii, }: { - item: SectionItem + item: ProjectHeaderItem onToggle: (worktree: string) => void onNew: (worktree: string) => void + colors: { + border: string + surface: string + text: string + textSecondary: string + textTertiary: string + surfaceRaised: string + } + radii: { md: number } }) { - const theme = useTheme() const parts = item.project.worktree.split(/[\\/]/).filter(Boolean) const name = parts[parts.length - 1] || item.project.worktree @@ -357,122 +500,81 @@ const ProjectRow = memo(function ProjectRow({ style={[ styles.projectRow, { - borderColor: theme.colors.border, - backgroundColor: item.active ? theme.colors.surface : "transparent", - borderRadius: theme.radii.md, + borderColor: colors.border, + backgroundColor: item.active ? colors.surface : "transparent", + borderRadius: radii.md, }, ]} onPress={() => onToggle(item.project.worktree)} > - - + + {name} - + {item.project.worktree} { event.stopPropagation() onNew(item.project.worktree) }} hitSlop={8} > - + - {item.count} + {item.count} ) }) -const ProjectSection = memo(function ProjectSection({ - item, - onToggle, - onNew, - onSelect, - onArchive, - onDelete, - onShare, - activeSessionID, +const LoadMoreRow = memo(function LoadMoreRow({ + worktree, + remaining, + chunk, + onLoadMore, + borderColor, + surfaceColor, + textSecondaryColor, }: { - item: SectionItem - onToggle: (worktree: string) => void - onNew: (worktree: string) => void - onSelect: (session: Session) => void | Promise - onArchive: (id: string) => void - onDelete: (id: string) => void - onShare: (id: string) => void - activeSessionID: string | null + worktree: string + remaining: number + chunk: number + onLoadMore: (worktree: string) => void + borderColor: string + surfaceColor: string + textSecondaryColor: string }) { - const theme = useTheme() - const [visibleCount, setVisibleCount] = useState(SESSION_RENDER_CHUNK) - const visibleSessions = useMemo( - () => item.sessions.slice(0, Math.max(SESSION_RENDER_CHUNK, visibleCount)), - [item.sessions, visibleCount], - ) - const remainingCount = Math.max(0, item.sessions.length - visibleSessions.length) - - useEffect(() => { - setVisibleCount(SESSION_RENDER_CHUNK) - }, [item.project.worktree, item.sessions.length]) - return ( - - - {!item.collapsed && item.sessions.length > 0 ? ( - - {visibleSessions.map((session, index) => ( - - - - ))} - {remainingCount > 0 ? ( - [ - styles.loadMoreSessionsButton, - { borderColor: theme.colors.border, backgroundColor: theme.colors.surface }, - pressed && styles.headerIconButtonPressed, - ]} - onPress={() => setVisibleCount((count) => count + SESSION_RENDER_CHUNK)} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel="Load more sessions" - > - - Show {Math.min(remainingCount, SESSION_RENDER_CHUNK)} more ({remainingCount} remaining) - - - ) : null} - - ) : null} - + [ + styles.loadMoreSessionsButton, + styles.sessionIndent, + { borderColor, backgroundColor: surfaceColor }, + pressed && styles.headerIconButtonPressed, + ]} + onPress={() => onLoadMore(worktree)} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel="Load more sessions" + > + + Show {chunk} more ({remaining} remaining) + + ) }) @@ -483,6 +585,9 @@ const SessionRow = memo(function SessionRow({ onDelete, onShare, activeSessionID, + textColor, + tertiaryColor, + surfaceColor, }: { session: Session onSelect: (session: Session) => void | Promise @@ -490,8 +595,10 @@ const SessionRow = memo(function SessionRow({ onDelete: (id: string) => void onShare: (id: string) => void activeSessionID: string | null + textColor: string + tertiaryColor: string + surfaceColor: string }) { - const theme = useTheme() const selected = activeSessionID === session.id const handleSelect = useCallback(() => { @@ -512,7 +619,8 @@ const SessionRow = memo(function SessionRow({ [ styles.sessionRow, - selected && { backgroundColor: theme.colors.surface, borderWidth: 1, borderColor: theme.colors.accent }, + styles.sessionIndent, + selected && { backgroundColor: surfaceColor }, { opacity: pressed ? 0.72 : 1 }, ]} onPress={handleSelect} @@ -521,10 +629,10 @@ const SessionRow = memo(function SessionRow({ hitSlop={6} > - + {session.title || "Untitled session"} - {relative(session.time.updated)} + {relative(session.time.updated)} ) @@ -534,13 +642,18 @@ const HeaderIconButton = memo(function HeaderIconButton({ icon, label, onPress, + textSecondaryColor, + surfaceColor, + borderColor, }: { icon: "compose" | "collapse-all" | "expand-all" | "settings" label: string onPress: () => void + textSecondaryColor: string + surfaceColor: string + borderColor: string }) { - const theme = useTheme() - const color = theme.colors.textSecondary + const color = textSecondaryColor const iconName = icon === "compose" ? "edit-3" @@ -577,7 +690,7 @@ const HeaderIconButton = memo(function HeaderIconButton({ style={({ pressed }) => [ styles.headerIconButton, styles.headerIconButtonFallback, - { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }, + { backgroundColor: surfaceColor, borderColor }, pressed && styles.headerIconButtonPressed, ]} onPress={onPress} @@ -731,25 +844,8 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: "transparent", }, - sessionRowSelected: { - borderRadius: 10, - borderWidth: StyleSheet.hairlineWidth, - borderColor: "rgba(255,255,255,0.15)", - }, - sessionGroup: { - marginLeft: 22, - marginTop: 8, - paddingLeft: 12, - paddingRight: 4, - paddingBottom: 8, - borderLeftWidth: StyleSheet.hairlineWidth, - gap: 8, - }, - sessionSlot: { - borderRadius: 12, - }, - sessionSlotLast: { - marginBottom: 2, + sessionIndent: { + marginLeft: 34, }, sessionMain: { flex: 1, diff --git a/packages/mobile/src/perf/telemetry.ts b/packages/mobile/src/perf/telemetry.ts new file mode 100644 index 000000000000..0263d71e2c73 --- /dev/null +++ b/packages/mobile/src/perf/telemetry.ts @@ -0,0 +1,106 @@ +type EventCategory = "chat" | "session" | "diff" | "sidebar" | "composer" | "drawer" | "api" | "error" | "perf" | "ui" + +type TelemetryEntry = { + at: number + category: EventCategory + event: string + data?: Record + duration?: number +} + +type GlobalWithTelemetry = typeof globalThis & { + __OPENCODE_MOBILE_TELEMETRY__?: TelemetryEntry[] + __OPENCODE_MOBILE_TELEMETRY_DEBUG__?: boolean +} + +const MAX_ENTRIES = 320 +const DATA_STRING_LIMIT = 200 + +function state(): GlobalWithTelemetry { + return globalThis as GlobalWithTelemetry +} + +function store() { + const g = state() + if (!Array.isArray(g.__OPENCODE_MOBILE_TELEMETRY__)) { + g.__OPENCODE_MOBILE_TELEMETRY__ = [] + } + return g.__OPENCODE_MOBILE_TELEMETRY__ +} + +function compact(value: unknown): unknown { + if (typeof value === "string") + return value.length <= DATA_STRING_LIMIT ? value : `${value.slice(0, DATA_STRING_LIMIT)}...` + if (typeof value === "number" || typeof value === "boolean" || value == null) return value + if (Array.isArray(value)) return value.slice(0, 8).map(compact) + if (typeof value === "object") { + const entries = Object.entries(value as Record).slice(0, 14) + const result: Record = {} + for (const [k, v] of entries) result[k] = compact(v) + return result + } + return String(value) +} + +function push(entry: TelemetryEntry) { + const s = store() + s.push(entry) + if (s.length > MAX_ENTRIES) s.splice(0, s.length - MAX_ENTRIES) + + if (state().__OPENCODE_MOBILE_TELEMETRY_DEBUG__) { + const dur = entry.duration ? ` (${entry.duration.toFixed(1)}ms)` : "" + console.log(`[telemetry:${entry.category}] ${entry.event}${dur}`, entry.data ?? "") + } +} + +function now() { + return globalThis.performance?.now?.() ?? Date.now() +} + +export function track(category: EventCategory, event: string, data?: Record) { + push({ + at: Date.now(), + category, + event, + data: data ? (compact(data) as Record) : undefined, + }) +} + +type Span = { + end: (data?: Record) => void +} + +export function span(category: EventCategory, event: string, data?: Record): Span { + const start = now() + return { + end(extra) { + const merged = data || extra ? ({ ...data, ...extra } as Record) : undefined + push({ + at: Date.now(), + category, + event, + data: merged ? (compact(merged) as Record) : undefined, + duration: now() - start, + }) + }, + } +} + +export function error(category: EventCategory, event: string, data?: Record) { + push({ + at: Date.now(), + category, + event: `error:${event}`, + data: data ? (compact(data) as Record) : undefined, + }) +} + +export function entries() { + return [...store()] +} + +export function clear() { + store().length = 0 +} + +export const telemetry = { track, span, error, entries, clear } diff --git a/packages/mobile/src/store/diffs.ts b/packages/mobile/src/store/diffs.ts index bb3ed96cd662..789b96651ea0 100644 --- a/packages/mobile/src/store/diffs.ts +++ b/packages/mobile/src/store/diffs.ts @@ -6,6 +6,7 @@ import type { DiffSummary, SessionFileDiff } from "../features/diff/types" import { normalizeDiffList } from "../features/diff/normalize" import { useSessions } from "./sessions" import { toErrorMessage } from "../util/error-message" +import { telemetry } from "../perf/telemetry" const DIFF_CACHE_TTL_MS = 30_000 @@ -114,7 +115,9 @@ export const useDiffs = createStore((set, get) => ({ })) try { + const s = telemetry.span("diff", "diff:fetch", { sessionID }) const normalized = await fetchDiffForSession(sessionID) + s.end({ files: normalized.length }) set((prev) => ({ bySession: { ...prev.bySession, @@ -149,6 +152,7 @@ export const useDiffs = createStore((set, get) => ({ setSessionDiff: (sessionID, diff) => { const normalized = normalizeDiffList(diff) + telemetry.track("diff", "diff:set", { sessionID, files: normalized.length }) set((prev) => ({ bySession: { ...prev.bySession, diff --git a/packages/mobile/src/store/messages.ts b/packages/mobile/src/store/messages.ts index 9d0df86f2491..e47560414dbc 100644 --- a/packages/mobile/src/store/messages.ts +++ b/packages/mobile/src/store/messages.ts @@ -7,6 +7,7 @@ import { useSettings } from "./settings" import { normalizeServerUrl } from "../util/server" import { markChatFirstToken, markStreamLateDelta } from "../perf/chat-metrics" import { addCrashBreadcrumb } from "../perf/crash-breadcrumbs" +import { telemetry } from "../perf/telemetry" type MessagePartDeltaEvent = { type: "message.part.delta" @@ -43,10 +44,7 @@ type MessageState = { hydrated: Record sessionOrder: string[] reset: () => void - load: ( - sessionID: string, - opts?: { force?: boolean; limit?: number; compact?: boolean; trimToRecent?: number }, - ) => Promise + load: (sessionID: string, opts?: { force?: boolean; limit?: number; trimToRecent?: number }) => Promise loadMore: (sessionID: string) => Promise prefetch: (sessionIDs: string[], opts?: { limit?: number }) => Promise hydrateMessage: (sessionID: string, messageID: string) => Promise @@ -71,8 +69,8 @@ type CachedSession = { const MESSAGE_CACHE_TTL_MS = 5 * 60_000 const PERSISTED_CACHE_TTL_MS = 24 * 60 * 60_000 -const INITIAL_MESSAGE_LIMIT = 60 -const LOAD_MORE_STEP = 80 +const INITIAL_MESSAGE_LIMIT = 6 +const LOAD_MORE_STEP = 20 const PREFETCH_LIMIT = 12 const MAX_MESSAGE_LIMIT = 5_000 const FAST_TRIM_SKIP_CLEANUP_THRESHOLD = 320 @@ -86,6 +84,12 @@ const PENDING_DELTA_TRIM_TARGET = 900 const persistTimers = new Map>() +// Synchronous in-process cache — survives Zustand LRU eviction. +// Keyed by sessionID, holds the same shape as CachedSession. +// Written whenever entries are applied; read synchronously in load() before +// touching AsyncStorage, making evicted-session restores instant. +const memorySessionCache = new Map() + export class SendMessageError extends Error { readonly sessionID: string readonly content: string @@ -373,6 +377,8 @@ export const useMessages = createStore((set, get) => { // ignore cache write errors }) lastPersistAt.set(sessionID, Date.now()) + // Keep fast memory cache in sync with the persisted payload. + memorySessionCache.set(sessionID, payload) }, delay) persistTimers.set(sessionID, timer) @@ -431,6 +437,17 @@ export const useMessages = createStore((set, get) => { ...trimStateForLRU(next, sessionID), } }) + + // Mirror to fast synchronous cache so LRU-evicted sessions restore instantly. + const saved = Date.now() + const capped = + entries.length > MAX_PERSISTED_MESSAGES_PER_SESSION ? entries.slice(-MAX_PERSISTED_MESSAGES_PER_SESSION) : entries + memorySessionCache.set(sessionID, { + savedAt: saved, + oldestCursor: capped[0]?.info.id ?? null, + exhausted: capped.length < limit, + entries: capped, + }) } return { @@ -476,6 +493,7 @@ export const useMessages = createStore((set, get) => { }, load: async (sessionID, opts) => { + const s = telemetry.span("chat", "messages:load", { sessionID, limit: opts?.limit }) const startAt = Date.now() addCrashBreadcrumb("messages-load:start", { sessionID, @@ -492,6 +510,7 @@ export const useMessages = createStore((set, get) => { const loadedAt = state.loadedAt[sessionID] if (!opts?.force && trimToRecent > 0 && hasMessages && (state.messages[sessionID]?.length ?? 0) > trimToRecent) { + s.end({ path: "trim" }) set((prev) => { const existing = prev.messages[sessionID] ?? [] if (existing.length <= trimToRecent) { @@ -578,6 +597,7 @@ export const useMessages = createStore((set, get) => { ageMs: now - loadedAt, count: state.messages[sessionID]?.length ?? 0, }) + s.end({ path: "memory-ttl", count: state.messages[sessionID]?.length ?? 0 }) set((prev) => ({ sessionOrder: touchSessionOrder(prev.sessionOrder, sessionID), })) @@ -590,6 +610,7 @@ export const useMessages = createStore((set, get) => { ageMs: loadedAt ? now - loadedAt : -1, count: state.messages[sessionID]?.length ?? 0, }) + s.end({ path: "memory", count: state.messages[sessionID]?.length ?? 0 }) set((prev) => ({ sessionOrder: touchSessionOrder(prev.sessionOrder, sessionID), })) @@ -597,6 +618,24 @@ export const useMessages = createStore((set, get) => { } if (!hasMessages && !opts?.force) { + // Fast path: synchronous in-process cache — no I/O, survives LRU eviction. + const memCached = memorySessionCache.get(sessionID) + if (memCached && memCached.entries.length > 0) { + const cacheAge = now - memCached.savedAt + applyLoadedEntries(sessionID, memCached.entries, limit, memCached.savedAt) + addCrashBreadcrumb("messages-load:mem-cache-hit", { + sessionID, + ageMs: cacheAge, + count: memCached.entries.length, + }) + s.end({ path: "mem-cache", count: memCached.entries.length }) + // Background refresh if the in-process copy is stale. + if (cacheAge >= MESSAGE_CACHE_TTL_MS) { + void get().load(sessionID, { force: true, limit }) + } + return + } + const cached = await readCachedSession(sessionID) if (cached && cached.entries.length > 0) { const cacheAge = now - cached.savedAt @@ -612,6 +651,7 @@ export const useMessages = createStore((set, get) => { ageMs: cacheAge, }) } + s.end({ path: "cache", count: cached.entries.length }) return } } @@ -629,7 +669,6 @@ export const useMessages = createStore((set, get) => { path: { id: sessionID }, query: { limit, - compact: opts?.compact ?? true, }, }) const elapsed = (globalThis.performance?.now?.() ?? Date.now()) - requestStart @@ -644,12 +683,14 @@ export const useMessages = createStore((set, get) => { elapsedMs: Math.round(elapsed), totalMs: Date.now() - startAt, }) + s.end({ path: "network", count: entries.length, elapsedMs: Math.round(elapsed) }) if (__DEV__) { const bytes = JSON.stringify(result.data).length console.log(`[chat-load] ${sessionID} limit=${limit} time=${elapsed.toFixed(1)}ms bytes=${bytes}`) } } } catch (error) { + s.end({ path: "error" }) addCrashBreadcrumb( "messages-load:error", { @@ -673,6 +714,8 @@ export const useMessages = createStore((set, get) => { const state = get() if (state.loading[sessionID] || state.exhausted[sessionID]) return + telemetry.track("chat", "messages:loadMore", { sessionID }) + const beforeMessageID = state.oldestCursor[sessionID] ?? state.messages[sessionID]?.[0]?.id if (!beforeMessageID) { set((prev) => ({ @@ -787,7 +830,7 @@ export const useMessages = createStore((set, get) => { })) try { - await get().load(sessionID, { limit, compact: true }) + await get().load(sessionID, { limit }) } finally { set((prev) => ({ prefetching: { @@ -803,6 +846,8 @@ export const useMessages = createStore((set, get) => { const state = get() if (state.hydrated[messageID] || state.hydrating[messageID]) return + telemetry.track("chat", "messages:hydrate", { sessionID, messageID }) + set((prev) => ({ hydrating: { ...prev.hydrating, @@ -877,6 +922,7 @@ export const useMessages = createStore((set, get) => { }, send: async (sessionID, content) => { + telemetry.track("chat", "messages:send", { sessionID, length: content.length }) const id = `optimistic-user-${Date.now()}` const optimisticUser: Message = { id, @@ -988,6 +1034,7 @@ export const useMessages = createStore((set, get) => { }, sendNew: async (content) => { + telemetry.track("chat", "messages:sendNew", { length: content.length }) const session = await useSessions.getState().create() try { await get().send(session.id, content) @@ -1006,6 +1053,7 @@ export const useMessages = createStore((set, get) => { }, abort: async (sessionID) => { + telemetry.track("chat", "messages:abort", { sessionID }) try { await client().session.abort({ path: { id: sessionID } }) } catch { @@ -1167,7 +1215,6 @@ export const useMessages = createStore((set, get) => { parts[messageID] = next partIndexByMessage[messageID] = partIndex hydrated[messageID] = isHydratedParts(next) - incrementVersion(partsVersionBySession, sessionID) loadedAt[sessionID] = Date.now() if (field === "text" && (nextPart.type === "text" || nextPart.type === "reasoning")) { markChatFirstToken(sessionID) @@ -1187,7 +1234,9 @@ export const useMessages = createStore((set, get) => { const partIndex = partIndexByMessage[messageID] ?? buildPartIndex(existing) const idx = partIndex[partID] ?? -1 const next = - idx >= 0 ? [...existing.slice(0, idx), ...existing.slice(idx + 1)] : existing.filter((part) => part.id !== partID) + idx >= 0 + ? [...existing.slice(0, idx), ...existing.slice(idx + 1)] + : existing.filter((part) => part.id !== partID) parts[messageID] = next partIndexByMessage[messageID] = buildPartIndex(next) hydrated[messageID] = isHydratedParts(next) @@ -1226,10 +1275,8 @@ export const useMessages = createStore((set, get) => { sessionOrder, } - return { - ...next, - ...trimStateForLRU(next), - } + const trimmed = next.sessionOrder.length > MAX_ACTIVE_SESSIONS ? trimStateForLRU(next) : null + return trimmed ? { ...next, ...trimmed } : next }) for (const sessionID of touchedForPersist) { @@ -1265,9 +1312,7 @@ export const useMessages = createStore((set, get) => { }, _removePart: (messageID, partID) => { - const sessionID = Object.entries(get().messages).find(([, sessionMessages]) => - sessionMessages.some((message) => message.id === messageID), - )?.[0] + const sessionID = Object.entries(get().messageIndexBySession).find(([, index]) => messageID in index)?.[0] if (!sessionID) { set((state) => { const next = (state.parts[messageID] ?? []).filter((part) => part.id !== partID) diff --git a/packages/mobile/src/store/sessions.ts b/packages/mobile/src/store/sessions.ts index ef22052b5b52..1a02d168e812 100644 --- a/packages/mobile/src/store/sessions.ts +++ b/packages/mobile/src/store/sessions.ts @@ -3,6 +3,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/client" import type { Project, Session, SessionStatus } from "@opencode-ai/sdk/client" import { client, url, headers } from "../api/client" import { addCrashBreadcrumb } from "../perf/crash-breadcrumbs" +import { telemetry } from "../perf/telemetry" function projectClient(worktree: string) { const base = headers() @@ -62,9 +63,13 @@ export const useSessions = createStore((set, get) => ({ loading: false, }), - select: (id) => set({ current: id }), + select: (id) => { + telemetry.track("session", "session:select", { sessionID: id }) + set({ current: id }) + }, fetch: async () => { + const s = telemetry.span("session", "session:fetch") const startAt = Date.now() addCrashBreadcrumb("sessions-fetch:start") set({ loading: true }) @@ -91,6 +96,7 @@ export const useSessions = createStore((set, get) => ({ sessionByID[session.id] = session } set({ projects, sessions: deduped, sessionByID, loading: false }) + s.end({ projects: projects.length, sessions: deduped.length }) addCrashBreadcrumb("sessions-fetch:done", { projects: projects.length, sessions: deduped.length, @@ -98,6 +104,7 @@ export const useSessions = createStore((set, get) => ({ }) } catch { set({ loading: false }) + s.end({ error: true }) addCrashBreadcrumb( "sessions-fetch:error", { @@ -146,14 +153,17 @@ export const useSessions = createStore((set, get) => ({ }, create: async () => { + const s = telemetry.span("session", "session:create") const result = await client().session.create() if (!result.data) throw new Error("Failed to create session") get()._upsert(result.data) set({ current: result.data.id }) + s.end({ sessionID: result.data.id }) return result.data }, archive: async (id) => { + telemetry.track("session", "session:archive", { sessionID: id }) try { await client().session.update({ path: { id }, @@ -173,6 +183,7 @@ export const useSessions = createStore((set, get) => ({ }, delete: async (id) => { + telemetry.track("session", "session:delete", { sessionID: id }) await client().session.delete({ path: { id } }) get()._remove(id) if (get().current === id) {