InstantPage V2: render server-sent thinking blocks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-30 18:47:03 +02:00
parent 484b66f28c
commit e8de87dc7b
15 changed files with 329 additions and 1135 deletions

View file

@ -62,9 +62,7 @@ Rare exceptions: top-level view-controller views integrating with the system's f
## AI streaming animation (rich-text bubbles)
`ChatMessageRichDataBubbleContentNode` shows a "Thinking…" shimmer header and progressively reveals InstantPage V2 content while `TypingDraftMessageAttribute` is on the message. Mirrors the older animation in `ChatMessageTextBubbleContentNode`, adapted to the heterogeneous V2 layout.
Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md`](docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md). Plan: [`docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md`](docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md).
`ChatMessageRichDataBubbleContentNode` progressively reveals InstantPage V2 content while `TypingDraftMessageAttribute` is on the message. Mirrors the older animation in `ChatMessageTextBubbleContentNode`, adapted to the heterogeneous V2 layout. The "Thinking…" indicator is now server-sent as `InstantPageBlock.thinking` rendered inside the pageView (see "InstantPage thinking blocks" section).
### Where things live
@ -74,18 +72,18 @@ Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2TextView`) | Drawing split: private `TextRenderView` does `draw(_)` inside a `renderContainer` whose layer carries a `revealMaskLayer`; new chars spawn cropped `SnippetLayer` siblings of the render container that animate in (blur + alpha + scale + position) and are absorbed into the mask on completion. Ported from `InteractiveTextComponent`. |
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `InstantPageV2RevealCostMap` + `InstantPageV2View.applyReveal(revealedCount:costMap:animated:)`. Bridges the global width-based cursor to per-text-view char counts (via `charCountForWidthBudget`) and per-item visibility / table-row pop-in. |
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageTextLine.characterRects` (line-local CT coords, baseline-relative positive-up) populated when `computeRevealCharacterRects: true` is passed to `layoutInstantPageV2(...)`. Uses `CTFontGetBoundingRectsForGlyphs` for actual glyph ink, not advance widths. |
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Streaming detection (`TypingDraftMessageAttribute`), "Thinking…" header layout, display-link wiring, container sizing. |
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Streaming detection (`TypingDraftMessageAttribute`), display-link wiring, container sizing. The hardcoded "Thinking…" header was removed; thinking is now rendered by the pageView via `InstantPageBlock.thinking`. |
### Non-obvious invariants
- **Cost unit is points of width, not characters.** Each item's cost = its width in points along the reading direction. Text contributes sum of glyph ink widths; non-text items contribute `frame.width`. Table cells are floored at `cell.frame.width` so narrow- or empty-cell tables don't race through the cursor. Reveal pace becomes "points per second" — uniform across content types.
- **Mask uses per-glyph ink bounds, unioned per line.** Each revealed glyph's mask rect comes from `CTFontGetBoundingRectsForGlyphs` (not advance widths) so italics, accents, descenders are covered exactly. Per line, glyphs are unioned into one mask rect; consecutive fully-revealed lines union further — fully-revealed prefix is always one `CALayer`.
- **`containerNode` does ALL the clipping.** During streaming, containerNode is sized to `streamingHeaderOffset + revealedItemsMaxY` (no closing pad). The bubble itself is taller (`revealedContentSize.height + 2`) — the strip below containerNode is empty bubble background. pageView keeps its full `pageLayout.contentSize`; anything past containerNode's bottom is clipped at containerNode (`clipsToBounds = true` set in init). Do NOT shorten the pageView or set `pageView.clipsToBounds`.
- **The pageView is rebuilt on every `stableVersion` bump.** V2View's render context is constructor-fixed, so each AI chunk creates a brand-new `InstantPageV2View`. The reveal cursor survives on `TextRevealController` (owned by the bubble). The seed call `applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)` after `ensurePageView` re-applies state to the fresh V2View so there's no flash of full-text-then-mask.
- **`containerNode` does ALL the clipping.** During streaming, containerNode is sized to `revealedItemsMaxY` (no header offset, no closing pad; `streamingHeaderOffset` is `0.0`). The bubble itself is taller (`revealedContentSize.height + 2`) — the strip below containerNode is empty bubble background. pageView keeps its full `pageLayout.contentSize`; anything past containerNode's bottom is clipped at containerNode (`clipsToBounds = true` set in init). Do NOT shorten the pageView or set `pageView.clipsToBounds`.
- **The pageView is REUSED across `stableVersion` bumps for the same message id.** `ensurePageView` calls `existing.renderContext?.updateContent(webpage:)` (where `webpage` is now a `public private(set) var` with an `updateContent` mutator) and returns the existing view; `update(layout:)` then diffs item views by stable id, tearing down only views whose block was removed. The pageView is rebuilt only when the bubble is recycled with a different message or webpage. The reveal cursor on `TextRevealController` persists across chunks; the seed re-apply (`applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)`) is now a continuation from the reused views' state, eliminating the per-chunk flash-of-full-text-then-mask that required the earlier from-scratch re-seed.
- **Layout cache key includes `message.stableVersion`.** Each AI chunk bumps stableVersion; without this the cached layout would shadow newly-arrived content.
- **`TypingDraftMessageAttribute` is the streaming gate.** Same trigger TextBubble uses. The InstantPage's `isComplete` flag is informational only.
- **Width-based cost → char count bridge.** Mask APIs (`updateRevealCharacterCount`) still take character counts. `applyRevealEntry` calls `charCountForWidthBudget(textItem:widthBudget:)` to translate the width-based local cursor into the per-text-view character count.
- **`Thinking…` header positioning matches TextBubble.** `streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top)`. `streamingHeaderOffset` = visible bottom + 1pt spacing = where pageView's `frame.origin.y` and statusFrame y-shift attach. Bubble minimum width includes `visible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2`.
- **The hardcoded "Thinking…" header was removed.** `streamingStatusTextNode`, `streamingStatusShimmerView`, and the header-layout machinery no longer exist. `streamingHeaderOffset` is now a constant `0.0` — the pageView starts at the top of the bubble. The "Thinking…" indicator is now server-sent as `InstantPageBlock.thinking` and rendered inside the pageView (see "InstantPage thinking blocks" section below).
- **Display-link tick re-layouts on extent change.** Tick reads `revealedContentSize` at the new cursor; if the height differs from the previous cursor, calls `requestFullUpdate`. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passes `animated: true` to `applyReveal` to fire the snippet pop-in.
### Status node (date/time/checks) positioning
@ -95,7 +93,7 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
- **X is a fixed left edge, not the last line's `minX`.** Anchor x = `pageHorizontalInset` (10pt, the page layout's text inset; pageView sits at self-x 0). The status layout is measured with `boundingWidth - 2·pageHorizontalInset` (mirrors TextBubble's `boundingWidth - sideInsets`) so the right-aligned date lands at the right inset instead of off the bubble. Using `lastTextLineFrame.minX` (which is large for nested/indented last lines) shoved the date off to the right.
- **Trail the last line only when the bottom-most item is text.** `lastTextLineFrameIfLastItemIsText(in:)` (in `InstantPageV2Layout.swift`) returns the last line frame *only* when the bottom-most top-level item (max `maxY`) is a `.text`; otherwise nil, so the date wraps below all content (anchored at `contentSize.height`). For tables/images/etc. the date must not trail text buried above the final item.
- **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (an inline animated emoji, ~`pointSize·24/17`, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case.
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY (+ streamingHeaderOffset)` in the apply closure exactly, or the date will be clipped/misplaced. 6pt matches TextBubble's bottom bubble inset.
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY` in the apply closure exactly, or the date will be clipped/misplaced. (`streamingHeaderOffset` is `0.0` — there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset.
- **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant.
## Inline custom emoji (RichText.textCustomEmoji)
@ -119,8 +117,8 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
- **`ChatTextInputTextCustomEmojiAttribute` is reused end-to-end** (display layer ⇄ layout model). The attribute is written to the placeholder in `attributedStringForRichText` and read back by the V2 line-breaker under the SAME key (`ChatTextInputAttributes.customEmoji`); `InlineStickerItemLayer.init` consumes it directly and resolves the file lazily from `fileId`.
- **Emoji participates in the streaming reveal.** Its placeholder char's `characterRect` is overwritten to a full cell (width = `itemSize`, baseline-relative bottom at `y=0`), so the width-based cost map charges it like other content. `updateEmojiReveal` pops the layer in (alpha 0→1 + scale) when `charIndexInItem < currentRevealCharacterCount`; unrevealed → opacity 0.
- **Layers sit ABOVE the reveal mask.** They attach to `InstantPageV2TextView.emojiContainerView` (a sibling above `renderContainer`), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead.
- **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is rebuilt per `stableVersion` bump (see streaming section), so the dict starts fresh each chunk — no orphan/leak across rebuilds; within one view, stale keys are pruned.
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set (because `streamingHeaderOffset` shifts across chunks without a `visibility` change). `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
- **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is now REUSED across `stableVersion` bumps (see streaming section), so the inline-emoji dict PERSISTS across chunks; `updateInlineEmoji` prunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass.
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set. `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
- **CTRunDelegate extent buffers must be freed.** Every inline-attachment arm (`.image`/`.formula`/`.textCustomEmoji`) in `attributedStringForRichText` allocates an `extentBuffer`; the `dealloc` callback must `deallocate()` it (it re-runs per layout pass).
## RichText entity cases (mention / hashtag / bot command / bank card / auto link)
@ -249,7 +247,7 @@ Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](
- **API bits are `checkbox`=flags.0, `checked`=flags.1 on ALL FOUR list-item constructors** (`pageListItemText`/`Blocks` and `pageListOrderedItemText`/`Blocks`, in and out — `pageListItemText#2f58683c`, `pageListOrderedItemText#cd3ea036`, etc.). The iOS `Api.*` layer exposes only `flags: Int32`; mask the bits (`apiFlags(fromChecked:)` / `checkedFromApiFlags`). Because state rides the flags (not the text), it survives the server round-trip for sender + recipients — **including the sender's own send-confirmation echo** (`applyUpdateMessage` replaces local attributes with the server's reconstruction, `ApplyUpdateMessage.swift`).
- **Tri-state persistence `0=nil, 1=unchecked, 2=checked`** in BOTH Postbox (key `"ck"`, decoded with `decodeInt32ForKey(orElse: 0)`) and FlatBuffers (`checkState:int32`, default 0). Absent/0 → `nil`, so pre-existing stored pages decode unchanged.
- **Detection reads `item.checked != nil`** in both layout engines (was `instantPageTaskListMarkerState(item.num)`); the V2 marker kind is `.checklist(checked: item.checked == true, colors:)`. The empty-blocks `.blocks → .text(.plain(" "), num, checked)` promotion must carry `checked` through, not drop it.
- **V2 `CheckNode` is hosted directly in a plain `UIView`**, not an ASDisplayNode tree, so `checkNode.displaysAsynchronously = false` is set to avoid a first-draw blank flash (the V2 pageView is rebuilt per streaming chunk — see the AI streaming section). `InstantPageV2CheckboxColors` (background←`panelAccentColor`, stroke←`pageBackgroundColor`, border←`controlColor`) is carried on the `.checklist` payload and mirrors the V1 `instantPageChecklistMarkerTheme`.
- **V2 `CheckNode` is hosted directly in a plain `UIView`**, not an ASDisplayNode tree, so `checkNode.displaysAsynchronously = false` is set to avoid a first-draw blank flash. (The V2 pageView is now REUSED across streaming chunks via stable-id diffing — see the AI streaming section; `CheckNode` views survive across chunks as long as their list item is present.) `InstantPageV2CheckboxColors` (background←`panelAccentColor`, stroke←`pageBackgroundColor`, border←`controlColor`) is carried on the `.checklist` payload and mirrors the V1 `instantPageChecklistMarkerTheme`.
- **Forward parser keeps `[ ]` detection but routes to `checked`.** `markdownApplyTaskListMarker`/`markdownStrippingTaskListMarker`/`markdownTaskListMarker` still strip the marker from the item text; the state flows into `checked` while ordered items keep their real `"\(ordinal)"` number. The reverse converter emits lowercase `[x]` / `[ ]`, which the forward `hasPrefix` guards re-parse — that is the round-trip contract.
- **The enum-arity change is compile-enforced.** Adding the third associated value broke every `.text`/`.blocks` construction/destructure; the full build is the completeness gate. Read-only consumers outside the core set exist (`BrowserInstantPageContent.swift`, `CachedFaqInstantPage.swift`) — grep `\.(text|blocks)\(` repo-wide when touching the enum again.
@ -257,7 +255,7 @@ Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](
`InstantPageBlock.blockQuote` carries `(blocks: [InstantPageBlock], caption: RichText)` — a sequence of nested page blocks (paragraphs, headings, lists, code, even nested quotes), not the legacy text-only payload. `.pullQuote` is unchanged (still `(text: RichText, caption: RichText)`; the TL API has no `pullQuoteBlocks` constructor).
Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md`](docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md). Plan: [`docs/superpowers/plans/2026-05-29-instantpage-blockquote-blocks.md`](docs/superpowers/plans/2026-05-29-instantpage-blockquote-blocks.md).
Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md`](docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md).
### Where things live
@ -281,6 +279,27 @@ Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.m
- **Entity-expressibility:** a quote is entity-expressible (→ regular message path) only if its caption is empty AND every child is an entity-expressible `.paragraph`. A nested-structure or multi-paragraph quote is not, so it sends via the rich path. **Behavior change:** markdown `> p1\n>\n> p2` is now ONE quote with two paragraphs (rich) rather than two consecutive entity quotes — correct semantics.
- **The enum-arity change is compile-enforced** across all modules; the full Bazel build is the completeness gate (no per-module build). `CachedFaqInstantPage.swift` matches `case .blockQuote:` payload-less and needs no edit. `BrowserReadability.swift` constructs `.blockQuote(blocks: [.paragraph(.italic(...))], …)` and is easy to miss in the spec's file list — grep `\.blockQuote(` repo-wide when touching the case again.
## InstantPage thinking blocks (InstantPageBlock.thinking)
`InstantPageBlock.thinking(RichText)` renders server-sent reasoning as dimmed, continuously-shimmering text inside rich-data bubbles. V2 renderer only; V1 ignores the block (returns `[]`). The shimmer and fade-in mechanics are deliberately separate from the char-reveal cursor so thinking blocks do not affect the reveal pacing of the answer content that follows them.
### Where things live
| File | Responsibility |
|---|---|
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageV2ThinkingItem` layout item + `layoutThinking(...)` (paragraph color × 0.55 alpha for the dimmed style) + `layoutBlock` `.thinking` arm. |
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2ThinkingView` — a `ShimmeringMaskView` wrapping a private inner `InstantPageV2TextView`; `InstantPageV2StableItemId.thinking(Int)` stable-id namespace; `makeItemView`/`reuse`/`stableId` arms for the `.thinking` item kind; the two-counter (content + thinking) stable-id loop in `InstantPageV2View.update`. |
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `.thinking(start:)` cost entry: contributes **zero** cursor cost; triggers whole-block alpha fade-in when `revealedCount >= start`. |
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 has no explicit `.thinking` case — it falls through `layoutInstantPageBlock`'s `default:` to an empty layout (no-op). |
### Non-obvious invariants
- **Zero reveal cost is the linchpin.** Thinking blocks do not advance the width-based cursor, so the answer's reveal position is identical whether or not thinking blocks are present — and is unaffected as they appear and disappear across streaming chunks. The answer text always reveals at the same rate regardless of how much thinking precedes it.
- **Whole-block fade, not char reveal.** The inner text is drawn fully under the shimmer mask at all times; the reveal mechanism is a simple alpha visibility keyed to the block's `start` index. A top-of-page thinking block (`start == 0`) is visible from the very first frame.
- **Shimmer runs continuously while the view is displayed** via `ShimmeringMaskView`'s `HierarchyTrackingLayer` self-animation. It does not stop when streaming ends.
- **Top-level only; separate stable-id namespace.** Thinking blocks appear only at the top level of the page. They use the `InstantPageV2StableItemId.thinking(Int)` namespace, numbered by a counter independent of content blocks. This means adding or removing a thinking block never renumbers the stable ids of content blocks — which, combined with pageView reuse, ensures content views and reveal state persist as thinking blocks come and go across chunks.
- **V1 is a no-op.** `InstantPageLayout.swift` has no `.thinking` case; the block falls through `layoutInstantPageBlock`'s `default:` to an empty layout, so V1 rendering silently skips it.
## Postbox → TelegramEngine refactor (in progress)
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.

View file

@ -1,642 +0,0 @@
# InstantPage V2 thinking-block rendering + cross-update view reuse — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Render top-level `InstantPageBlock.thinking(RichText)` as a dimmed, always-shimmering, reusable block in the InstantPage V2 renderer (zero reveal-cost, whole-block fade-in at its index); remove the hardcoded "Thinking…" header from the rich-data bubble; and make the streaming pageView reuse views across chunks instead of rebuilding wholesale.
**Architecture:** A new `InstantPageV2LaidOutItem.thinking` case carries a single dimmed `InstantPageTextItem`; a new `InstantPageV2ThinkingView` hosts that text inside a `ShimmeringMaskView`; a new zero-cost `.thinking` reveal-cost entry fades the whole view in when the cursor reaches its index. The pageView stops rebuilding per `stableVersion` by making `InstantPageV2RenderContext.webpage` mutable and switching `ensurePageView` to update-in-place, leaning on the renderer's existing stable-id diffing — with thinking blocks placed in their own stable-id namespace so their churn never renumbers content blocks.
**Tech Stack:** Swift, UIKit, AsyncDisplayKit, CoreText; Bazel via `build-system/Make/Make.py`. **This repo has NO unit tests and NO per-module build** (CLAUDE.md) — the only build is the full `Telegram/Telegram` target, which is also the enum-arity completeness gate. Verification is therefore: (a) the full Bazel build at the integration checkpoints below, and (b) manual streaming inspection in Task 11.
---
## Spec
`docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md`
## Build command (the integration gate)
```sh
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
--cacheDir ~/telegram-bazel-cache \
build \
--configurationPath build-system/appstore-configuration.json \
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
--continueOnError
```
`--continueOnError` surfaces every error in one pass (the enum-arity change touches many switches). The controller must drive this build directly (run_in_background + capture real exit code), not a subagent — see the `feedback_subagent_build_execution` memory.
## Completeness-gate switch inventory (every exhaustive switch over `InstantPageV2LaidOutItem` / cost-map `Entry`)
Adding `InstantPageV2LaidOutItem.thinking` breaks these and they MUST each gain a `.thinking` arm (those with a `default:` need no edit — listed for completeness):
| File | Symbol | Line (approx) | Has `default:`? |
|---|---|---|---|
| `InstantPageV2Layout.swift` | `collectMedias` | 47 | **yes — no edit** |
| `InstantPageV2Layout.swift` | `frame` computed prop | 90 | no — **edit** |
| `InstantPageV2Layout.swift` | `offsetBy` | 113 | no — **edit** |
| `InstantPageV2Layout.swift` | `layoutBlock` (switches `InstantPageBlock`, not the laid-out item) | 892 (`case .thinking`) | n/a — **replace stub** |
| `InstantPageRenderer.swift` | `reuse` | 572 | no — **edit** |
| `InstantPageRenderer.swift` | `stableId(for:atPosition:)` | 636 | no — **edit** |
| `InstantPageRenderer.swift` | `makeItemView` | 700 | no — **edit** |
| `InstantPageV2RevealCost.swift` | `computeEntries` | 258 | no — **edit** |
| `InstantPageV2RevealCost.swift` | `revealedExtent` (switches cost `Entry`) | 159 | no — **edit (new Entry case)** |
| `InstantPageV2RevealCost.swift` | `applyRevealEntry` (switches cost `Entry`) | 380 | no — **edit (new Entry case)** |
---
## Task 1: Add the `.thinking` laid-out item model
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (enum + 2 switches)
- [ ] **Step 1: Add the item struct.** After the `InstantPageV2CodeBlockItem` struct (ends at `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:144`), insert:
```swift
public struct InstantPageV2ThinkingItem {
public var frame: CGRect
/// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never
/// char-reveal-masked); the shimmer + whole-block fade are the only animations.
public let textItem: InstantPageTextItem
}
```
- [ ] **Step 2: Add the enum case.** In `InstantPageV2LaidOutItem` (the `case formula(...)` line is `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:87`), add after it:
```swift
case thinking(InstantPageV2ThinkingItem)
```
- [ ] **Step 3: Handle it in the `frame` computed property.** In the switch starting at `InstantPageV2Layout.swift:90`, after `case let .formula(item): return item.frame` add:
```swift
case let .thinking(item): return item.frame
```
- [ ] **Step 4: Handle it in `offsetBy`.** In the switch starting at `InstantPageV2Layout.swift:113`, after the `.formula` line add:
```swift
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
```
- [ ] **Step 5: Commit** (will not build standalone — no per-module build exists; full build runs at the Task 6 checkpoint).
```bash
git add submodules/InstantPageUI/Sources/InstantPageV2Layout.swift
git commit -m "InstantPage V2: add .thinking laid-out item model"
```
---
## Task 2: Lay out the thinking block
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (new `layoutThinking` + `layoutBlock` arm)
- [ ] **Step 1: Add `layoutThinking`.** Insert immediately after `layoutCodeBlock` (which ends at `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:1945`):
```swift
private func layoutThinking(
_ text: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps
// its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old
// hardcoded "Thinking…" header, which used the message theme's dimmed description color).
let base = context.theme.textCategories.paragraph
let dimmedAttributes = InstantPageTextAttributes(
font: base.font,
color: base.color.withAlphaComponent(0.55),
underline: false
)
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}
```
- [ ] **Step 2: Replace the `layoutBlock` stub.** At `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:892`, replace:
```swift
case .thinking:
return []
```
with:
```swift
case let .thinking(text):
return layoutThinking(text, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
```
- [ ] **Step 3: Commit.**
```bash
git add submodules/InstantPageUI/Sources/InstantPageV2Layout.swift
git commit -m "InstantPage V2: lay out .thinking block as dimmed text"
```
---
## Task 3: Add the `ShimmeringMask` dependency to InstantPageUI
**Files:**
- Modify: `submodules/InstantPageUI/BUILD`
- [ ] **Step 1: Inspect current deps.** Run:
```bash
grep -n "ComponentFlow\|HierarchyTracking\|ShimmeringMask\|deps = \[" submodules/InstantPageUI/BUILD
```
Expected: `//submodules/ComponentFlow:ComponentFlow` present; no `ShimmeringMask`.
- [ ] **Step 2: Add the dep.** In the `deps = [ ... ]` list of the `swift_library` in `submodules/InstantPageUI/BUILD`, add (next to the existing `ComponentFlow` line, keeping the list's existing ordering/indentation):
```python
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
```
`ShimmeringMask` itself depends on `ComponentFlow`, `Display`, and `HierarchyTrackingLayer`; those resolve transitively, so no other dep line is required.
- [ ] **Step 3: Commit.**
```bash
git add submodules/InstantPageUI/BUILD
git commit -m "InstantPageUI: depend on ShimmeringMask for thinking-block shimmer"
```
---
## Task 4: Add `InstantPageV2ThinkingView` + renderer wiring
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (import, stable-id enum, new view class, `makeItemView`, `reuse`, `stableId`)
- [ ] **Step 1: Import ShimmeringMask.** At the top of `submodules/InstantPageUI/Sources/InstantPageRenderer.swift`, with the other imports, add:
```swift
import ShimmeringMask
```
- [ ] **Step 2: Add the stable-id case.** In `InstantPageV2StableItemId` (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift:29`), after `case details(Int)` add:
```swift
case thinking(Int) // thinking-block sequence index (own namespace)
```
- [ ] **Step 3: Add the view class.** Immediately after `InstantPageV2CodeBlockView` (ends at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:1849`), insert:
```swift
// MARK: - Thinking view (dimmed shimmering reasoning block)
/// A top-level thinking block: dimmed text drawn fully, masked by a continuously-running
/// `ShimmeringMaskView`. Reveal is whole-block alpha (driven from the cost map), NOT char-by-char,
/// and the block contributes zero reveal cost. Structure mirrors `InstantPageV2CodeBlockView`
/// (container hosting an inner `InstantPageV2TextView`).
final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ThinkingItem
var itemFrame: CGRect { return self.item.frame }
private let shimmerView: ShimmeringMaskView
let textView: InstantPageV2TextView
init(item: InstantPageV2ThinkingItem) {
self.item = item
self.shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.addSubview(self.shimmerView)
self.shimmerView.contentView.addSubview(self.textView)
self.layoutContents()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// Parent positions children (see CLAUDE.md "View frame ownership"): the shimmer covers the
/// whole block; the inner text view sits at its block-local typographic frame (expanded by the
/// text view's clipping inset, matching `InstantPageV2TextView.init`).
private func layoutContents() {
self.shimmerView.frame = CGRect(origin: .zero, size: self.item.frame.size)
self.textView.frame = self.item.textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
self.shimmerView.update(
size: self.item.frame.size,
containerWidth: self.item.frame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
}
func update(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView.update(item: innerV2TextItem, theme: theme)
self.layoutContents()
}
}
```
- [ ] **Step 4: Wire `makeItemView`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:700`, after the `case let .formula(formula):` arm (the last one), add:
```swift
case let .thinking(thinking):
return InstantPageV2ThinkingView(item: thinking)
```
- [ ] **Step 5: Wire `reuse`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:572`, after the last media arm (before the closing `}` of the switch), add:
```swift
case let .thinking(thinking):
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
v.update(item: thinking, theme: theme)
return v
```
- [ ] **Step 6: Wire `stableId`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:636`, after `case let .formula(...)` add:
```swift
case .thinking: return .thinking(position)
```
(The `update()` loop passes a thinking-specific index here — see Task 5.)
- [ ] **Step 7: Commit.**
```bash
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
git commit -m "InstantPage V2: add InstantPageV2ThinkingView + renderer wiring"
```
---
## Task 5: Separate stable-id namespaces in the update loop
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (the `update()` loop at lines 215-242)
- [ ] **Step 1: Replace the enumerate loop header.** At `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:215`, replace:
```swift
for (position, item) in layout.items.enumerated() {
let id = InstantPageV2View.stableId(for: item, atPosition: position)
```
with:
```swift
// Two independent position counters so thinking-block churn never renumbers content
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
// blocks). Content items are numbered ignoring thinking items; thinking items get their
// own .thinking(index) namespace.
var contentPosition = 0
var thinkingPosition = 0
for item in layout.items {
let id: InstantPageV2StableItemId
if case .thinking = item {
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
thinkingPosition += 1
} else {
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
contentPosition += 1
}
```
(The rest of the loop body — the `if let existing … else …` block, lines 218-241 — is unchanged. Confirm the loop's closing brace still matches.)
- [ ] **Step 2: Verify no other use of the old `position` variable.** Run:
```bash
sed -n '215,243p' submodules/InstantPageUI/Sources/InstantPageRenderer.swift
```
Expected: the only references to a position value inside the loop are the two `stableId(... atPosition:)` calls just edited; `actualFrame(forItem:)` and the rest use `item`/`reusedView`, not `position`.
- [ ] **Step 3: Commit.**
```bash
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
git commit -m "InstantPage V2: namespace thinking vs content stable ids"
```
---
## Task 6: Zero-cost reveal entry for thinking + first build checkpoint
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` (Entry enum + 3 switches + clear path)
- [ ] **Step 1: Add the Entry case.** In the `fileprivate enum Entry` (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:14`), after `case nonText(start: Int, end: Int)` add:
```swift
case thinking(start: Int)
```
- [ ] **Step 2: Emit a zero-cost entry in `computeEntries`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:258`, add a dedicated arm BEFORE the grouped `case .formula, .mediaImage, …` arm (line 326):
```swift
case .thinking:
// Zero cost: do NOT advance the cursor. This is the linchpin — answer-content cursor
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
```
- [ ] **Step 3: Contribute height when revealed in `revealedExtent`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:159`, after the `case let .nonText(start, end):` arm add:
```swift
case let .thinking(start):
// Revealed (and contributes its full height) once the cursor reaches its index position.
// A top thinking block (start == 0) is revealed from the first frame.
if revealedCount < start { return nil }
return item.frame
```
- [ ] **Step 4: Fade the whole view in `applyRevealEntry`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:380`, after the `case let .nonText(start, end):` arm add:
```swift
case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
// (never char-reveal-masked) — the shimmer is the only ongoing animation.
let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated)
```
- [ ] **Step 5: Handle the clear path.** In `clearRevealOn` (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:533`), after the `InstantPageV2TableView` block (ends ~line 551, before the final `applyVisibility(view: view, visible: true, …)` line) add:
```swift
if let thinkingView = view as? InstantPageV2ThinkingView {
// Defensive: the inner text is never char-masked, but ensure it's full on clear.
thinkingView.textView.updateRevealCharacterCount(value: nil, animated: animated)
}
```
- [ ] **Step 6: Build checkpoint (full Bazel build).** This is the first point where all InstantPageUI enum-arity edits are complete. Run the build command from the top of this plan (controller-driven, run_in_background, capture exit).
- Expected: **BUILD SUCCESSFUL**, or only errors in files this plan has not yet touched.
- If errors: every error should name one of the inventory switches above or a typo in the new code — fix inline before committing.
- [ ] **Step 7: Commit.**
```bash
git add submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift
git commit -m "InstantPage V2: zero-cost reveal entry + whole-block fade for thinking"
```
---
## Task 7: Make the render context updatable
**Files:**
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2RenderContext`)
- [ ] **Step 1: Make `webpage` mutable + add an update method.** In `InstantPageV2RenderContext` (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift:49`), change:
```swift
public let webpage: TelegramMediaWebpage
```
to:
```swift
public private(set) var webpage: TelegramMediaWebpage
```
Then, immediately after the `init(...)` closing brace (currently `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:80`), add:
```swift
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage
}
```
- [ ] **Step 2: Commit.**
```bash
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
git commit -m "InstantPage V2: make render context webpage updatable"
```
---
## Task 8: Reuse the pageView across chunks (`ensurePageView`)
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (`ensurePageView`, lines 104-145)
- [ ] **Step 1: Update-in-place on a stableVersion-only change.** In `ensurePageView` (`…/ChatMessageRichDataBubbleContentNode.swift:104`), replace the early-return + teardown block (lines 105-113):
```swift
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
if let existing = self.pageView,
let current = self.pageViewMessageKey,
current.id == key.id,
current.stableVersion == key.stableVersion {
return existing
}
self.pageView?.removeFromSuperview()
self.pageView = nil
```
with:
```swift
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
if current.stableVersion == key.stableVersion {
return existing
}
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
// the existing render context; the subsequent pageView.update(layout:) call diffs item
// views by stable id (content blocks keep their ids — see Task 5 — so their views and
// in-flight reveal state persist; only added/removed blocks change). This replaces the
// old wholesale rebuild and eliminates the per-chunk full-text-then-mask flash.
existing.renderContext?.updateContent(webpage: webpage)
self.pageViewMessageKey = key
return existing
}
self.pageView?.removeFromSuperview()
self.pageView = nil
```
- [ ] **Step 2: Commit.**
```bash
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
git commit -m "RichData bubble: reuse pageView across streaming chunks"
```
---
## Task 9: Remove the hardcoded "Thinking…" header
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`
This removes the built-in header and collapses `streamingHeaderOffset` to a constant `0`. Do the edits in order; several reference each other.
- [ ] **Step 1: Remove the stored properties.** Delete lines `…:52-53`:
```swift
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
private var streamingStatusShimmerView: ShimmeringMaskView?
```
- [ ] **Step 2: Remove the async-layout closure capture.** Delete line `…:177`:
```swift
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
```
- [ ] **Step 3: Remove the header layout block.** Delete the whole `streamingTextLayoutAndApply` block (`…:409-425`):
```swift
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
backgroundColor: nil,
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: textConstrainedSize,
alignment: .natural,
cutout: nil,
insets: textInsets,
lineColor: messageTheme.accentControlColor,
customTruncationToken: nil,
computeCharacterRects: true
))
}
```
- [ ] **Step 4: Replace the streaming-frame / offset block.** Replace `…:431-467` (the `var streamingTextFrame …` through the `boundingSize.height += streamingHeaderOffset` block) with a constant-zero offset:
```swift
// The hardcoded "Thinking…" header was removed in favor of server-sent
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
// header strip anymore, so the page content starts at the top of the bubble.
let streamingHeaderOffset: CGFloat = 0.0
```
(This deletes `streamingTextFrame`, the `thinkingMinBubbleWidth` width bump, the `boundingSize.height += streamingHeaderOffset` line, and the `streamingTextSpacing` use. Keep `streamingHeaderOffset` as a `let 0.0` so the downstream references in Step 6 still compile; a follow-up could inline it, but leaving it avoids a wide diff.)
- [ ] **Step 5: Remove the now-unused `streamingTextSpacing`.** Delete line `…:406`:
```swift
let streamingTextSpacing: CGFloat = 1.0
```
Then run `grep -n "streamingTextSpacing\|textConstrainedSize" submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — if `textConstrainedSize` (line ~408) is now unused (it fed only the deleted header layout), delete its declaration too. The build is warnings-as-error (`project_swift_warnings_as_errors` memory), so unused `let`s fail.
- [ ] **Step 6: Remove the apply-time header wiring.** Delete the entire "2. Update the "Thinking…" header." block — `…:765-820` — from the comment `// 2. Update the "Thinking…" header.` through the closing `}` of the `else if let streamingStatusShimmerView` branch (the block that ends by removing `streamingStatusShimmerView` from its superview). After deletion, the surrounding numbered comments ("1. Compute / cache the cost map." above, "3. Drive the reveal controller." below) should be adjacent.
- [ ] **Step 7: Remove the import.** Delete line `…:18`:
```swift
import ShimmeringMask
```
- [ ] **Step 8: Grep for stragglers.** Run:
```bash
grep -n "streamingStatus\|streamingTextFrame\|streamingTextLayoutAndApply\|thinkingMinBubbleWidth\|ShimmeringMask\|Thinking" submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
```
Expected: **no matches** (the only remaining `streamingHeaderOffset` references are the `let = 0.0` and the status-node `statusAnchorY`/`statusFrameY (+ streamingHeaderOffset)` arithmetic, which now add 0 and are harmless). If any `streamingStatus*` / `streamingTextFrame` / `Thinking` match remains, remove it.
- [ ] **Step 9: Commit.**
```bash
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
git commit -m "RichData bubble: remove hardcoded Thinking header (server-controlled now)"
```
---
## Task 10: Second build checkpoint (bubble changes)
**Files:** none (build + fix)
- [ ] **Step 1: Full Bazel build.** Run the build command from the top of this plan (controller-driven).
- Expected: **BUILD SUCCESSFUL**.
- Common failures to watch for: leftover reference to a deleted `streamingStatus*` symbol (Task 9), an unused-`let` warnings-as-error (`textConstrainedSize`/`streamingTextSpacing`), or `ShimmeringMask` import still present in the bubble. Fix inline.
- [ ] **Step 2: If fixes were needed, commit them.**
```bash
git add -A
git commit -m "Fix build after thinking-block integration"
```
---
## Task 11: Manual verification
**Files:** none
- [ ] **Step 1: Run the app on the simulator** (use the project's run tooling / XcodeBuildMCP, simulator `debug_sim_arm64`).
- [ ] **Step 2: Stream an AI rich message whose server payload includes one or more top-level `.thinking` blocks interleaved with answer content.** Confirm each, against the spec's Verification section:
- thinking blocks render in dimmed/secondary color and **shimmer continuously** (streaming and after completion);
- a top thinking block is visible from the start; a thinking block placed after content **fades in** (0.12s) as the reveal cursor passes its position;
- the answer's reveal pace does **not** jump when a thinking block appears or disappears across chunks (zero-cost);
- across chunks, answer-content blocks are **reused** — no per-chunk full-text flash, no reveal reset;
- the bubble grows to contain revealed thinking blocks; the date/status node sits correctly with the header gone;
- a finalized message that still carries a thinking block renders it (shimmering).
- [ ] **Step 3: Capture a screen recording / screenshots** for the PR and note any visual deltas from the spec.
---
## Task 12: Update CLAUDE.md invariants
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Rewrite the flipped invariants in the "AI streaming animation" section.** Update:
- The bullet "**The pageView is rebuilt on every `stableVersion` bump.**" → describe the new behavior: the pageView is now **reused** across `stableVersion` bumps (same message id); `ensurePageView` updates the render context's webpage and lets `update(layout:)` diff by stable id. Views are torn down only when their block is removed.
- The inline-emoji note "**the dict starts fresh each chunk — no orphan/leak across rebuilds**" → the dict now **persists** across chunks; stale keys are pruned within the view by `updateInlineEmoji`/`updateInlineImages`.
- The "Status node positioning" / `streamingHeaderOffset` notes → the hardcoded "Thinking…" header is gone; `streamingHeaderOffset` is a constant `0`; thinking content is server-sent `InstantPageBlock.thinking` rendered inside the pageView.
- [ ] **Step 2: Add a new subsection documenting thinking blocks** (under the InstantPage sections), covering: top-level only; `InstantPageV2LaidOutItem.thinking` + `InstantPageV2ThinkingView` (shimmer over dimmed text); zero reveal-cost + whole-block fade at index; separate `.thinking(Int)` stable-id namespace; V1 no-op. Link the spec + this plan.
- [ ] **Step 3: Commit.**
```bash
git add CLAUDE.md
git commit -m "Docs: thinking blocks + pageView cross-chunk reuse invariants"
```
---
## Self-review notes (for the implementer)
- **Spec coverage:** Part B.2 → Task 2; B.3 → Tasks 3-4; B.4 → Task 6; A.1 → Task 7; A.2 → Task 8; A.3 → Task 5; A.4 → Task 9; CLAUDE.md risk → Task 12; V1 no-op → unchanged (the spec requires no V1 edit; `InstantPageLayout.swift` has no `InstantPageV2LaidOutItem` switch). Visual style / shimmer-always / dimmed color → Tasks 2 + 4.
- **Type consistency:** `InstantPageV2ThinkingItem` (frame + textItem) is constructed in Task 2 and consumed in Tasks 4 (view) and 6 (cost). `InstantPageV2ThinkingView.textView` (exposed `let`) is read by `clearRevealOn` in Task 6. `InstantPageV2StableItemId.thinking(Int)` defined in Task 4, produced in Tasks 4 (`stableId`) + 5 (loop). Cost `Entry.thinking(start:)` defined in Task 6 and handled in all three Entry switches in the same task.
- **No per-module build:** intermediate tasks are not independently buildable; the two full-build checkpoints (Tasks 6 and 10) are the real gates.

View file

@ -1,232 +0,0 @@
# Server-controlled thinking blocks + cross-update view reuse (InstantPage V2)
**Date:** 2026-05-29
**Status:** Approved (design)
## Summary
Render the top-level `InstantPageBlock.thinking(RichText)` block in the InstantPage **V2**
renderer as a dimmed, always-shimmering, reusable block view. The visual treatment matches the
existing `streamingStatusTextNode` + `streamingStatusShimmerView` "Thinking…" header — but driven
by **server-sent** content instead of a hardcoded string, and packaged as a first-class V2 block
item view. The hardcoded header in `ChatMessageRichDataBubbleContentNode` is removed.
This is a **two-part, coupled** design:
- **Part A — Cross-update view reuse.** Stop rebuilding the entire `InstantPageV2View` on every
streaming chunk (`stableVersion` bump). Reuse item views across page updates; tear a view down
only when its block is actually removed. This is what lets thinking blocks "come and go" across
chunks without disturbing the answer content's views or reveal animation.
- **Part B — Thinking block rendering.** Lay out, display, and reveal-animate the thinking block;
remove the hardcoded header. V1 ignores thinking blocks (no-op).
The two parts are coupled: thinking blocks are explicitly allowed to appear and disappear across
chunks, and the requirement that this "should not affect other blocks" is only meaningful once
views survive across chunks (Part A) and thinking carries a separate id namespace + zero reveal
cost (Part B).
## Motivation / requirements (from the requester)
1. Add layout display of `InstantPageBlock.thinking` in the InstantPage **V2** renderer. V1 may
ignore them.
2. The built-in hardcoded "Thinking…" implementation at
`ChatMessageRichDataBubbleContentNode.swift:412` must be **removed** in favor of
server-controlled thinking blocks.
3. Take care of the message reveal animation.
4. Thinking blocks **may come and go** across chunks, and they must **not** take part in reveal
cost estimation. Yet they must follow the reveal fade-in *as if* they had a cost position:
reveal (fade in) when `revealedCount >= the thinking block's index position`.
5. Thinking blocks may appear **only at the top level** of the instant page. There may be **any
number** of them.
6. Adding or removing thinking blocks must **not affect other blocks**. This implies a **separate
id assignment** for thinking blocks vs. all other blocks.
7. **Views should not be torn down under any page update unless they were removed. Views should be
reused whenever possible.**
## Design decisions (confirmed)
- **Visual style:** identical to the existing `streamingStatusTextNode` rendered inside a
`ShimmeringMaskView` mask — a shimmering text region — refactored into a reusable block item view.
- **Shimmer lifecycle:** the block shimmers **continuously while displayed**, streaming or not.
The `ShimmeringMaskView` self-animates via its `HierarchyTrackingLayer` whenever in-hierarchy, so
the view owns its own animation regardless of message state.
- **Base text color:** **dimmed / secondary** (matching the old header's
`messageTheme.fileDescriptionColor`). RichText keeps its own bold/italic/link/inline-emoji
formatting on top of this base color.
- **Reveal mechanic:** **zero reveal-cost**; the whole block **alpha-fades in (0.12s)** once the
reveal cursor reaches its index position; the inner text is drawn **fully** (not char-by-char)
under the shimmer.
- **Header removal:** **fully remove** the hardcoded header and its `streamingHeaderOffset`
machinery. **No empty-state fallback** — the bubble is empty until the server sends its first
block (thinking or answer).
- **Placement:** thinking blocks appear only at the top level; any count; rendered in normal
block flow exactly where they sit in the sequence.
- **View reuse:** Part A is **in scope** for this task. `InstantPageV2RenderContext` becomes
updatable to enable it.
---
## Part B — Thinking block rendering
### B.1 Core model (`TelegramCore`)
`InstantPageBlock.thinking(RichText)` already exists (parsing, Postbox + FlatBuffers
serialization, API parse, `apiInputBlock` returns nil = output-only). **No core changes.** The
layout layer is the only thing that needs to learn to render it.
### B.2 Layout (`submodules/InstantPageUI/Sources/InstantPageV2Layout.swift`)
- Add `InstantPageV2LaidOutItem.thinking(InstantPageV2ThinkingItem)`. `InstantPageV2ThinkingItem`
holds:
- the inner laid-out text (an `InstantPageTextItem` / sub-text-layout produced like a paragraph
but using a **secondary/dimmed base color** for the default run color), and
- its own `frame`.
- Replace the `case .thinking: return []` stub (currently `InstantPageV2Layout.swift:892`) with an
arm that lays out the RichText (reusing the existing simple-text layout helper) and wraps it in
the thinking item. Normal inter-block spacing in the top-level flow.
- The dimmed base color is sourced from the existing theme. Reuse the caption/secondary text
category or pass the dimmed color explicitly; the exact category is an implementation detail to
match the old header color.
### B.3 View (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift`)
- Add `InstantPageV2ThinkingView: InstantPageItemView`. Structure mirrors
`InstantPageV2CodeBlockView` (a container hosting an inner text view):
- hosts a `ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)`;
- the shimmer's `contentView` holds the inner `InstantPageV2TextView` (so inline emoji, links,
bold/italic etc. all work via the standard text view);
- the shimmer self-animates whenever in-hierarchy → **always shimmering while displayed**;
- `update(item:theme:...)` refreshes the inner text view and re-runs
`ShimmeringMaskView.update(size:containerWidth:offsetX:gradientWidth:)`.
- `makeItemView` gains a `.thinking` arm constructing the view.
- `reuse` gains a `.thinking` arm calling the view's `update`.
- `stableId(for:atPosition:)` gains a `.thinking` arm → `.thinking(thinkingIndex)` (see A.3).
- **BUILD:** add `//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask` (and its
transitive `HierarchyTrackingLayer`, `ComponentFlow` is already present) to `InstantPageUI/BUILD`.
### B.4 Reveal cost (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift`)
- Add cost-map entry `.thinking(start: Int)`.
- `computeEntries`: append `.thinking(start: cursor)` and **do not advance `cursor`** (zero cost).
Consequence: the answer content's cursor positions are identical whether or not thinking blocks
are present → reveal cursor is stable through thinking churn (**the linchpin** of requirement 4).
- `revealedExtent(entry:item:revealedCount:)`: for `.thinking(start)`, return `item.frame` when
`revealedCount >= start`, else `nil`. So the block contributes height (grows the bubble) exactly
when it is revealed. A top thinking block (`start == 0`) contributes height from the first frame.
- `applyRevealEntry(view:entry:revealedCount:animated:)`: for `.thinking(start)`, set the inner
text view to its **full** character count (fully drawn, never reveal-masked), then
`applyVisibility(view: thinkingView, visible: revealedCount >= start, animated:)` (the existing
0.12s alpha cross-fade used for non-text items).
- A top thinking block (`start == 0`) is visible from the start of the reveal; one placed after
content fades in as the cursor passes its position.
### B.5 V1 (`submodules/InstantPageUI/Sources/InstantPageLayout.swift`)
`.thinking` stays a no-op (`[]`). V1 is unaffected.
---
## Part A — Cross-update view reuse
Today `ensurePageView` (`ChatMessageRichDataBubbleContentNode.swift:104`) rebuilds the
`InstantPageV2View` from scratch whenever `stableVersion` changes, because
`InstantPageV2RenderContext` is constructor-fixed (`let webpage`, closures capturing a
`MessageReference`). Every other update path (theme, width, details expand/collapse) already flows
through `InstantPageV2View.update(layout:)` and reuses item views via the renderer's stable-id
diffing. The **only** thing forcing a wholesale teardown each chunk is the `stableVersion` key.
### A.1 Updatable render context (`InstantPageRenderer.swift`)
Make `InstantPageV2RenderContext` content updatable for same-message chunks: convert `webpage`
(and the captured `MessageReference`) to mutable storage and add an
`updateContent(webpage:messageReference:)` method (or equivalent). The closures
(`imageReference`/`fileReference`) read the current stored `MessageReference` rather than capturing
a fixed snapshot. `context`, `sourceLocation`, and the navigation closures are unchanged across
chunks.
### A.2 `ensurePageView` update-in-place (`ChatMessageRichDataBubbleContentNode.swift`)
- **Same message id, new `stableVersion`** → keep the existing `pageView`, call
`updateContent(...)` on its render context, and fall through to `pageView.update(layout:)`. No
teardown.
- **Different message id / recycled bubble with a different webpage** → rebuild as today.
- The reveal seed (`applyReveal(revealedCount: seedCount, animated: false)`) becomes a continuation
of the live views' existing reveal state instead of a from-scratch re-seed — eliminating the
brief full-text-then-mask flash at each chunk boundary.
### A.3 Separate stable-id namespaces (`InstantPageRenderer.swift`)
`InstantPageV2StableItemId` gains a `.thinking(Int)` case. In the `update()` walk
(`InstantPageRenderer.swift:215`), maintain **two independent counters**:
- a **content counter** that increments only for non-thinking items; non-thinking items are
numbered by it (`.positional(kind, contentIndex)`);
- a **thinking counter** that increments only for thinking items; thinking items get
`.thinking(thinkingIndex)`.
Result: inserting or removing a thinking block no longer renumbers the content blocks' positional
ids, so their views (and their reveal masks/state) are reused and animate smoothly across chunks
(requirement 6).
### A.4 Bubble header removal (`ChatMessageRichDataBubbleContentNode.swift`)
Delete the hardcoded "Thinking…" header and its supporting machinery:
- `streamingStatusTextNode`, `streamingStatusShimmerView` properties and all their apply-time
wiring (the block around lines 765820);
- `streamingStatusTextLayout`, `streamingTextLayoutAndApply`, `streamingTextFrame`;
- the hardcoded `NSAttributedString(string: "Thinking...")` (line 413);
- `streamingHeaderOffset` (becomes a constant `0`): remove it from the bubble-height math
(`boundingSize.height += streamingHeaderOffset`), from the `thinkingMinBubbleWidth` width bump,
from the status-node `statusAnchorY`/`statusFrameY` shifts, and from `pageView.frame.origin.y`
(which becomes `0`, matching today's non-streaming position);
- `import ShimmeringMask` from the bubble file (the dependency moves to `InstantPageUI`).
No empty-state placeholder: while `hasDraft` and the page has no blocks yet, the bubble is empty.
---
## Risks / invariants
- **CLAUDE.md updates required.** The "AI streaming animation" section states "The pageView is
rebuilt on every `stableVersion` bump… each AI chunk creates a brand-new `InstantPageV2View`" and
"the dict starts fresh each chunk — no orphan/leak across rebuilds." Both invariants flip with
Part A: views and the inline-emoji/inline-image dicts now **persist across chunks**. Verify the
existing within-view stale-key pruning in `updateInlineEmoji`/`updateInlineImages` correctly
removes layers for blocks/emoji that disappear across a chunk (it prunes keys not present in the
current layout, so cross-chunk removal should be handled — confirm during implementation). The
header/`streamingHeaderOffset` notes in the "Status node positioning" subsection must be
rewritten.
- **Reveal cursor stability** depends entirely on thinking being **zero-cost**. If thinking ever
contributed cost, adding/removing a thinking block mid-stream would jump the answer's reveal
position. Keep cost at 0.
- **Enum-arity changes are compile-enforced.** Adding `InstantPageV2LaidOutItem.thinking` and
`InstantPageV2StableItemId.thinking` breaks every exhaustive switch over them (`computeEntries`,
`revealedExtent`, `applyRevealEntry`, `stableId`, `makeItemView`, `reuse`, and any preview/extent
walks). The **full Bazel build** is the completeness gate (no per-module build in this repo).
- **Render-context mutability** must not break media reference resolution: media resolves by media
id within the message, and the message id is stable across chunks, so updating the stored
`MessageReference` to the latest chunk is safe (and more correct than a stale snapshot).
- **Details/table reuse** already works via stable ids and `update(layout:)`; Part A must not
regress the existing collapse/expand animation (`finalizePendingCollapse`) — the same-id reuse
path is unchanged for those views.
## Out of scope
- Sending thinking blocks (the app is output-only for `.thinking`; `apiInputBlock` returns nil).
- V1 rendering of thinking blocks.
- Any change to how the server decides to include/exclude thinking blocks.
- Tappable interaction / collapse-expand for thinking blocks (they are plain shimmering text).
## Verification
- Full Bazel build (`Make.py … --configuration=debug_sim_arm64`) — the enum-arity completeness gate.
- Manual: stream an AI message whose server payload includes one or more top-level `.thinking`
blocks interleaved with answer content; confirm:
- thinking blocks shimmer continuously and render in dimmed/secondary color;
- thinking blocks fade in at their index position and never advance the answer's reveal;
- across chunks where thinking blocks appear/disappear, answer-content views are reused (no flash,
no reveal-position jump);
- the bubble grows to contain revealed thinking blocks and the status node sits correctly with the
header removed;
- a finalized message carrying a thinking block still renders it (shimmering).

View file

@ -35,6 +35,7 @@ swift_library(
"//submodules/Tuples:Tuples",
"//third-party/SwiftMath:SwiftMath",
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
],
visibility = [
"//visibility:public",

View file

@ -461,7 +461,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth) : 0.0
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth, kind: .topLevel) : 0.0
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing))
if previousBlock == nil {
originY += spacing
@ -778,7 +778,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
@ -962,7 +962,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
subitems.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
@ -970,7 +970,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
}
if !blocks.isEmpty {
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false, kind: .topLevel)
contentSize.height += closingSpacing
}
@ -1102,7 +1102,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
for i in 0 ..< pageBlocks.count {
let block = pageBlocks[i]
let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth, kind: .topLevel)
let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) {
@ -1111,7 +1111,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
}
}
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth, kind: .topLevel)
contentSize.height += closingSpacing
if webPage.webpageId.id != 0 && addFeedback {

View file

@ -2,19 +2,30 @@ import Foundation
import UIKit
import TelegramCore
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool) -> CGFloat {
enum BlockSequenceKind {
case topLevel
case detail
case cell
case list
}
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool, kind: BlockSequenceKind) -> CGFloat {
if let upper, let lower {
switch (upper, lower) {
case (_, .cover), (_, .channelBanner), (.details, .details), (.relatedArticles, _), (_, .anchor):
return 0.0
case (.divider, _), (_, .divider):
if fitToWidth {
return 10.0
return 20.0
} else {
return 25.0
}
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
return 27.0
if fitToWidth {
return 11.0
} else {
return 27.0
}
case (.kicker, .title), (.cover, .title):
return 16.0
case (_, .title):
@ -27,12 +38,22 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 34.0
case (.header, .paragraph), (.subheader, .paragraph), (.heading, .paragraph):
if fitToWidth {
return 10.0
return 14.0
} else {
return 25.0
}
case (.list, .paragraph):
return 31.0
if fitToWidth {
return 14.0
} else {
return 31.0
}
case (.paragraph, .list):
if fitToWidth {
return 14.0
} else {
return 31.0
}
case (.preformatted, .paragraph):
return 19.0
case (.formula, .paragraph):
@ -96,20 +117,37 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 0.0
default:
if fitToWidth {
return 10.0
switch kind {
case .topLevel:
switch lower {
case .heading:
return 13.0
default:
return 10.0
}
case .detail, .cell, .list:
return 4.0
}
} else {
return 25.0
}
}
} else if let upper {
switch kind {
case .topLevel:
if case .relatedArticles = upper {
return 0.0
} else {
if fitToWidth {
return 5.0
} else {
return 25.0
}
}
case .detail, .cell, .list:
return 16.0
}
} else {
if let upper, case .relatedArticles = upper {
return 0.0
} else {
if fitToWidth {
return 5.0
} else {
return 25.0
}
}
return 0.0
}
}

View file

@ -14,6 +14,7 @@ import EmojiTextAttachmentView
import AnimationCache
import MultiAnimationRenderer
import InvisibleInkDustNode
import ShimmeringMask
// MARK: - Stable item identity (for view reuse on re-layouts)
@ -29,6 +30,7 @@ import InvisibleInkDustNode
public enum InstantPageV2StableItemId: Hashable {
case media(Int) // media.index (4 media cases share this namespace)
case details(Int) // details.index
case thinking(Int) // thinking-block sequence index (own namespace)
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
}
@ -48,7 +50,7 @@ public enum InstantPageV2ItemKind: Hashable {
/// `InstantPageV2View()` constructor usable.
public final class InstantPageV2RenderContext {
public let context: AccountContext
public let webpage: TelegramMediaWebpage
public private(set) var webpage: TelegramMediaWebpage
public let sourceLocation: InstantPageSourceLocation
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
public let fileReference: (TelegramMediaFile) -> FileMediaReference
@ -78,6 +80,15 @@ public final class InstantPageV2RenderContext {
self.openUrl = openUrl
self.baseNavigationController = baseNavigationController
}
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage
}
}
// MARK: - Inline image view data
@ -212,8 +223,21 @@ public final class InstantPageV2View: UIView {
var newStableIds: [InstantPageV2StableItemId] = []
var reusedIds: Set<InstantPageV2StableItemId> = []
for (position, item) in layout.items.enumerated() {
let id = InstantPageV2View.stableId(for: item, atPosition: position)
// Two independent position counters so thinking-block churn never renumbers content
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
// blocks). Content items are numbered ignoring thinking items; thinking items get their
// own .thinking(index) namespace.
var contentPosition = 0
var thinkingPosition = 0
for item in layout.items {
let id: InstantPageV2StableItemId
if case .thinking = item {
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
thinkingPosition += 1
} else {
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
contentPosition += 1
}
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
@ -630,6 +654,10 @@ public final class InstantPageV2View: UIView {
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .thinking(thinking):
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
v.update(item: thinking, theme: theme)
return v
}
}
@ -650,6 +678,7 @@ public final class InstantPageV2View: UIView {
case .table: return .positional(.table, position)
case .anchor: return .positional(.anchor, position)
case .formula: return .positional(.formula, position)
case .thinking: return .thinking(position)
}
}
@ -748,6 +777,8 @@ public final class InstantPageV2View: UIView {
}
case let .formula(formula):
return InstantPageV2FormulaView(item: formula)
case let .thinking(thinking):
return InstantPageV2ThinkingView(item: thinking)
}
}
@ -1652,11 +1683,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleTextView.isUserInteractionEnabled = false
self.chevronView = UIImageView()
// Single downward chevron; the expanded state is a 180° rotation (animatable) rather than
// an instant chevron.up/chevron.down image swap. A template image + tintColor renders the
// SF Symbol in the message's primary text color baking the color into a CALayer's cgImage
// contents drops the tint and renders black. (SF Symbol is iOS 13+.)
self.chevronView.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate)
self.chevronView.image = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.contentMode = .scaleAspectFit
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
@ -1677,24 +1704,6 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.addSubview(self.chevronView)
self.addSubview(self.separator)
let chevronSize = CGSize(width: 18.0, height: 18.0)
// bounds + center (not frame) so the rotation transform pivots around the center and the
// frame stays well-defined while a non-identity transform is applied.
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.chevronView.layer.transform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
// V1 (InstantPageDetailsNode.swift:138): separator sits at titleHeight - UIScreenPixel.
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
)
if item.isExpanded, let innerLayout = item.innerLayout {
let body = InstantPageV2View(renderContext: renderContext)
body.update(layout: innerLayout, theme: theme, animation: .None)
@ -1727,20 +1736,12 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
)
self.titleTextView.update(item: titleV2Item, theme: theme)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.tintColor = theme.secondaryControlColor
let chevronSize = CGSize(width: 18.0, height: 18.0)
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.separator.backgroundColor = item.separatorColor
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
x: item.sideInset + chevronSize.width / 2.0,
y: item.titleFrame.midY + 1.0
)
self.titleHitView.frame = item.titleFrame
@ -1749,6 +1750,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
// view's own frame height (clipsToBounds = true), not by the body itself see
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
// *nested* details block inside the body can also animate its own toggle.
let blockHeight: CGFloat
if item.isExpanded {
if let innerLayout = item.innerLayout {
let body: InstantPageV2View
@ -1767,6 +1769,9 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
size: innerLayout.contentSize
)
blockHeight = body.frame.maxY
} else {
blockHeight = item.titleFrame.maxY
}
} else {
if let existingBody = self.bodyView {
@ -1780,7 +1785,16 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.bodyView = nil
}
}
blockHeight = item.titleFrame.maxY
}
self.separator.backgroundColor = item.separatorColor
animation.animator.updateFrame(layer: self.separator.layer, frame: CGRect(
x: 8.0,
y: blockHeight - UIScreenPixel,
width: item.frame.width - 8.0 * 2.0,
height: UIScreenPixel
), completion: nil)
// Chevron rotation. The body teardown on collapse is NOT tied to this completion see
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
@ -1848,6 +1862,58 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
}
}
// MARK: - Thinking view (dimmed shimmering reasoning block)
/// A top-level thinking block: dimmed text drawn fully, masked by a continuously-running
/// `ShimmeringMaskView`. Reveal is whole-block alpha (driven from the cost map), NOT char-by-char,
/// and the block contributes zero reveal cost. Structure mirrors `InstantPageV2CodeBlockView`
/// (container hosting an inner `InstantPageV2TextView`).
final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ThinkingItem
var itemFrame: CGRect { return self.item.frame }
private let shimmerView: ShimmeringMaskView
private let textView: InstantPageV2TextView
init(item: InstantPageV2ThinkingItem) {
self.item = item
self.shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.addSubview(self.shimmerView)
self.shimmerView.contentView.addSubview(self.textView)
self.layoutContents()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// Parent positions children (see CLAUDE.md "View frame ownership"): the shimmer covers the
/// whole block; the inner text view sits at its block-local typographic frame (expanded by the
/// text view's clipping inset, matching `InstantPageV2TextView.init`).
private func layoutContents() {
self.shimmerView.frame = CGRect(origin: .zero, size: self.item.frame.size)
self.textView.frame = self.item.textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
self.shimmerView.update(
size: self.item.frame.size,
containerWidth: self.item.frame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
}
func update(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView.update(item: innerV2TextItem, theme: theme)
self.layoutContents()
}
}
// MARK: - Table view
final class InstantPageV2TableView: UIView, InstantPageItemView {

View file

@ -131,12 +131,12 @@ public final class InstantPageTheme {
public let tableBorderColor: UIColor
public let tableHeaderColor: UIColor
public let controlColor: UIColor
public let imageTintColor: UIColor?
public let overlayPanelColor: UIColor
public let separatorColor: UIColor
public let secondaryControlColor: UIColor
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor) {
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor, separatorColor: UIColor, secondaryControlColor: UIColor) {
self.type = type
self.pageBackgroundColor = pageBackgroundColor
self.textCategories = textCategories
@ -156,10 +156,12 @@ public final class InstantPageTheme {
self.controlColor = controlColor
self.imageTintColor = imageTintColor
self.overlayPanelColor = overlayPanelColor
self.separatorColor = separatorColor
self.secondaryControlColor = secondaryControlColor
}
public func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTheme {
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor)
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor, separatorColor: separatorColor, secondaryControlColor: secondaryControlColor)
}
func headingTextAttributes(level: Int32, link: Bool) -> InstantPageTextAttributes {
@ -191,7 +193,7 @@ public final class InstantPageTheme {
baseSize = 13.0
}
let sizeMultiplier = subheaderAttributes.font.size / 19.0
let sizeMultiplier = subheaderAttributes.font.size / 18.0
let attributes = InstantPageTextAttributes(
font: InstantPageFont(style: .serif, size: floor(baseSize * sizeMultiplier), lineSpacingFactor: subheaderAttributes.font.lineSpacingFactor),
color: subheaderAttributes.color,
@ -229,7 +231,9 @@ private let lightTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0xf4f4f4),
controlColor: UIColor(rgb: 0xc7c7cd),
imageTintColor: nil,
overlayPanelColor: .white
overlayPanelColor: .white,
separatorColor: UIColor(rgb: 0xe2e2e2),
secondaryControlColor: .black
)
private let sepiaTheme = InstantPageTheme(
@ -260,7 +264,9 @@ private let sepiaTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0xf0e7d4),
controlColor: UIColor(rgb: 0xddd1b8),
imageTintColor: nil,
overlayPanelColor: UIColor(rgb: 0xf8f1e2)
overlayPanelColor: UIColor(rgb: 0xf8f1e2),
separatorColor: UIColor(rgb: 0xe2e2e2),
secondaryControlColor: .black
)
private let grayTheme = InstantPageTheme(
@ -291,7 +297,9 @@ private let grayTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0x555556),
controlColor: UIColor(rgb: 0x484848),
imageTintColor: UIColor(rgb: 0xcecece),
overlayPanelColor: UIColor(rgb: 0x5a5a5c)
overlayPanelColor: UIColor(rgb: 0x5a5a5c),
separatorColor: UIColor(rgb: 0x484848),
secondaryControlColor: .black
)
private let darkTheme = InstantPageTheme(
@ -322,7 +330,9 @@ private let darkTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0x131313),
controlColor: UIColor(rgb: 0x303030),
imageTintColor: UIColor(rgb: 0xb0b0b0),
overlayPanelColor: UIColor(rgb: 0x232323)
overlayPanelColor: UIColor(rgb: 0x232323),
separatorColor: UIColor(rgb: 0x303030),
secondaryControlColor: UIColor(rgb: 0xb0b0b0)
)
private func fontSizeMultiplierForVariant(_ variant: InstantPagePresentationFontSize) -> CGFloat {

View file

@ -85,6 +85,7 @@ public enum InstantPageV2LaidOutItem {
case mediaMap(InstantPageV2MediaMapItem)
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
case formula(InstantPageV2FormulaItem)
case thinking(InstantPageV2ThinkingItem)
public var frame: CGRect {
switch self {
@ -103,6 +104,7 @@ public enum InstantPageV2LaidOutItem {
case let .mediaMap(item): return item.frame
case let .mediaCoverImage(item): return item.frame
case let .formula(item): return item.frame
case let .thinking(item): return item.frame
}
}
@ -126,6 +128,7 @@ public enum InstantPageV2LaidOutItem {
case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item)
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
}
}
}
@ -143,6 +146,13 @@ public struct InstantPageV2CodeBlockItem {
public let inset: UIEdgeInsets
}
public struct InstantPageV2ThinkingItem {
public var frame: CGRect
/// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never
/// char-reveal-masked); the shimmer + whole-block fade are the only animations.
public let textItem: InstantPageTextItem
}
public struct InstantPageV2DividerItem {
public var frame: CGRect
public let color: UIColor
@ -283,6 +293,7 @@ public struct InstantPageV2MediaPlaceholderItem {
public struct InstantPageV2DetailsItem {
public var frame: CGRect
public let index: Int
public let sideInset: CGFloat
public let titleTextItem: InstantPageTextItem
public let titleFrame: CGRect // local to this item's frame
public let separatorColor: UIColor
@ -370,6 +381,7 @@ public func layoutInstantPageV2(
instantPage.blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .topLevel,
context: &context
)
}
@ -481,6 +493,7 @@ private func layoutBlockSequence(
_ blocks: [InstantPageBlock],
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
context: inout LayoutContext
) -> InstantPageV2Layout {
var items: [InstantPageV2LaidOutItem] = []
@ -489,7 +502,7 @@ private func layoutBlockSequence(
var previousBlock: InstantPageBlock?
for (i, block) in blocks.enumerated() {
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth, kind: kind)
let localItems = layoutBlock(
block,
boundingWidth: boundingWidth,
@ -521,7 +534,7 @@ private func layoutBlockSequence(
}
}
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth, kind: kind)
contentHeight += closingSpacing
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
@ -888,9 +901,9 @@ private func layoutBlock(
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
// Block kinds filled in by later tasks:
case .thinking:
return []
case let .thinking(text):
return layoutThinking(text, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case .unsupported:
return []
}
@ -983,16 +996,19 @@ private func layoutDetails(
let (titleTextItem, _, _) = layoutTextItem(
attributedStringForRichText(title, styleStack: titleStyleStack),
boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron
offset: CGPoint(x: horizontalInset, y: 12.0),
offset: CGPoint(x: 0.0, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let titleTextItem = titleTextItem else { return [] }
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
titleTextItem.frame.origin.x = horizontalInset + 32.0
titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5)
let isExpanded = context.expandedDetails[index] ?? defaultExpanded
// V1 uses max(44.0, titleSize.height + 26.0); matched here.
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
let titleFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: titleHeight)
var innerLayout: InstantPageV2Layout?
@ -1002,6 +1018,7 @@ private func layoutDetails(
blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .detail,
context: &context
)
innerLayout = layout
@ -1011,9 +1028,10 @@ private func layoutDetails(
let item = InstantPageV2DetailsItem(
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight),
index: index,
sideInset: horizontalInset,
titleTextItem: titleTextItem,
titleFrame: titleFrame,
separatorColor: context.theme.controlColor.withMultipliedAlpha(0.25),
separatorColor: context.theme.separatorColor,
isExpanded: isExpanded,
innerLayout: innerLayout,
defaultExpanded: defaultExpanded
@ -1279,6 +1297,7 @@ private func layoutTable(
[.paragraph(cellText)],
boundingWidth: cellContentWidth,
horizontalInset: 0.0,
kind: .cell,
context: &context
)
stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header))
@ -1503,6 +1522,7 @@ private func layoutTable(
[.paragraph(title)],
boundingWidth: totalWidth - v2TableCellInsets.left * 2.0,
horizontalInset: 0.0,
kind: .cell,
context: &context
)
titleSubLayout = titleLayout
@ -1944,6 +1964,38 @@ private func layoutCodeBlock(
))]
}
private func layoutThinking(
_ text: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps
// its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old
// hardcoded "Thinking" header, which used the message theme's dimmed description color).
let base = context.theme.textCategories.paragraph
let dimmedAttributes = InstantPageTextAttributes(
font: base.font,
color: base.color.withAlphaComponent(0.55),
underline: false
)
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}
// MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517586)
private func layoutBlockQuote(
@ -1962,7 +2014,7 @@ private func layoutBlockQuote(
}
let verticalInset: CGFloat = 4.0
let lineInset: CGFloat = 20.0
let lineInset: CGFloat = context.fitToWidth ? 12.0 : 20.0
let barWidth: CGFloat = 3.0
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
@ -2041,7 +2093,7 @@ private func layoutQuoteText(
// V1 line 518/553: verticalInset = 4.0 for both variants.
let verticalInset: CGFloat = 4.0
// V1 line 518: lineInset = 20.0 (blockQuote only; pullQuote uses full width).
let lineInset: CGFloat = isPull ? 0.0 : 20.0
let lineInset: CGFloat = isPull ? 0.0 : (context.fitToWidth ? 12.0 : 20.0)
var result: [InstantPageV2LaidOutItem] = []
var contentHeight: CGFloat = verticalInset // V1 line 520/554: starts at verticalInset
@ -2329,7 +2381,7 @@ private func layoutList(
context: &context
)
let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth) : 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0
let offsetX = horizontalInset + indexSpacing + maxIndexWidth
let offsetY = contentHeight + spacing
let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) }

View file

@ -14,6 +14,7 @@ extension InstantPageV2RevealCostMap {
fileprivate enum Entry {
case text(start: Int, end: Int)
case nonText(start: Int, end: Int)
case thinking(start: Int)
case details(start: Int, end: Int, body: InstantPageV2RevealCostMap?)
case codeBlock(start: Int, end: Int)
case table(start: Int, end: Int, rows: [TableRow], title: InstantPageV2RevealCostMap?)
@ -168,6 +169,11 @@ private func revealedExtent(entry: InstantPageV2RevealCostMap.Entry, item: Insta
let _ = start
if revealedCount < end { return nil }
return item.frame
case let .thinking(start):
// Revealed (and contributes its full height) once the cursor reaches its index position.
// A top thinking block (start == 0) is revealed from the first frame.
if revealedCount < start { return nil }
return item.frame
case let .codeBlock(start, _):
if revealedCount <= start { return nil }
// Block backdrop appears atomically once revealing reaches the block; inner text
@ -323,6 +329,11 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
rows.append(InstantPageV2RevealCostMap.TableRow(startCount: rowStart, cells: cellMaps))
}
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
case .thinking:
// Zero cost: do NOT advance the cursor. This is the linchpin answer-content cursor
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder,
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
let start = cursor
@ -419,6 +430,11 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
let visible = revealedCount >= end
applyVisibility(view: view, visible: visible, animated: animated)
let _ = start
case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
// (never char-reveal-masked) the shimmer is the only ongoing animation.
let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated)
}
}

View file

@ -1654,14 +1654,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
var allowFullWidth = false
let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId
var isInlinePage = false
/*let isInlinePage = false
for attribute in item.message.attributes {
if attribute is RichTextMessageAttribute {
allowFullWidth = true
isInlinePage = true
break
}
}
}*/
do {
let peerId = chatLocationPeerId
@ -1931,9 +1931,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
needsShareButton = false
}
if isInlinePage {
/*if isInlinePage {
needsShareButton = false
}
}*/
var tmpWidth: CGFloat
if allowFullWidth {

View file

@ -24,10 +24,7 @@ swift_library(
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUIPreferences",
"//submodules/TextSelectionNode",
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
"//submodules/TelegramUI/Components/InteractiveTextComponent:InteractiveTextComponent",
"//submodules/TelegramUI/Components/StreamingTextReveal:StreamingTextReveal",
"//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities",
],
visibility = [
"//visibility:public",

View file

@ -15,9 +15,6 @@ import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
import StreamingTextReveal
import ShimmeringMask
import InteractiveTextComponent
import TextNodeWithEntities
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
@ -49,9 +46,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
private var streamingStatusShimmerView: ShimmeringMaskView?
private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap?
@ -70,10 +64,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
// Pushes the current `visibility` sub-rect into `pageView.visibilityRect`, translated into the
// page view's coordinate space (the page view sits at `streamingHeaderOffset` inside the bubble).
// Re-invoked from the apply closure after `pageView.frame` is set, because that offset shifts
// across streamed chunks without a `visibility` change, which would otherwise leave the
// animation-gating rect stale.
// page view's coordinate space (the page view sits at the top of the bubble; no header offset).
// Re-invoked from the apply closure after `pageView.frame` is set, because the pageView's
// y-origin and size can change across streamed chunks (content growth) without a `visibility`
// change, which would otherwise leave the animation-gating rect stale.
private func updatePageViewVisibilityRect() {
guard let pageView = self.pageView else {
return
@ -99,14 +93,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.addSubnode(self.containerNode)
}
/// Builds (or reuses) the V2View. The render context is constructor-fixed on V2View, so
/// when the bubble is recycled with a different webpage we must rebuild the V2View.
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
/// the bubble is recycled with a different message/webpage (different message id).
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage) -> InstantPageV2View {
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
if let existing = self.pageView,
let current = self.pageViewMessageKey,
current.id == key.id,
current.stableVersion == key.stableVersion {
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
if current.stableVersion == key.stableVersion {
return existing
}
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
// the existing render context; the subsequent pageView.update(layout:) call diffs item
// views by stable id (content blocks keep their ids, so their views and in-flight
// reveal state persist; only added/removed blocks change). This replaces the old
// wholesale rebuild and eliminates the per-chunk full-text-then-mask flash.
existing.renderContext?.updateContent(webpage: webpage)
self.pageViewMessageKey = key
return existing
}
self.pageView?.removeFromSuperview()
@ -174,7 +176,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let previousItem = self.item
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
let currentPageLayout = self.currentPageLayout
let currentExpandedDetails = self.currentExpandedDetails
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
@ -188,25 +189,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
// topInset matches TextBubble's logic at lines 234-249 gives the "Thinking"
// header the same vertical alignment as TextBubble's status header does inside
// its bubble.
var topInset: CGFloat = 0.0
if case let .linear(top, _) = position {
switch top {
case .None:
topInset = layoutConstants.text.bubbleInsets.top
case let .Neighbour(_, topType, _):
switch topType {
case .text:
topInset = layoutConstants.text.bubbleInsets.top - 2.0
case .header, .footer, .media, .reactions:
topInset = layoutConstants.text.bubbleInsets.top
}
default:
topInset = layoutConstants.text.bubbleInsets.top
}
}
let suggestedBoundingWidth: CGFloat = constrainedSize.width
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
@ -219,7 +201,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// self-x 0 (containerNode at 1, pageView at -1 inside it), so the page's text
// left edge in the status node's coordinate space is exactly this value. Used as
// the status node's left edge + side inset, mirroring TextBubble's bubbleInsets.
let pageHorizontalInset: CGFloat = 10.0
let pageHorizontalInset: CGFloat = 11.0
let isDark = item.presentationData.theme.theme.overallDarkAppearance
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
@ -296,8 +278,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
let textCategories = InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
@ -323,7 +305,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: isIncoming ? UIColor(white: 0.0, alpha: 0.25): messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
)
var hasDraft = false
@ -401,70 +385,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
boundingSize.height = effectiveSize.height + 2.0
}
let textFont = item.presentationData.messageFont
let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0)
let streamingTextSpacing: CGFloat = 1.0
let textConstrainedSize = CGSize(width: suggestedBoundingWidth - 4.0, height: .greatestFiniteMagnitude)
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
backgroundColor: nil,
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: textConstrainedSize,
alignment: .natural,
cutout: nil,
insets: textInsets,
lineColor: messageTheme.accentControlColor,
customTruncationToken: nil,
computeCharacterRects: true
))
}
// Origin mirrors TextBubble:783 (bubbleInsets.left - textInsets.left,
// topInset - textInsets.top). The negative textInset offsets cancel the
// inset that's baked into the InteractiveTextNode layout, so the visible
// glyph origin aligns with (bubbleInsets.left, topInset).
var streamingTextFrame: CGRect?
if let streamingTextLayoutAndApply {
streamingTextFrame = CGRect(
origin: CGPoint(
x: layoutConstants.text.bubbleInsets.left - textInsets.left,
y: topInset - textInsets.top
),
size: streamingTextLayoutAndApply.layout.size
)
}
// Offset for the pageView (and status node y-shift) places the pageView
// right below the streaming header's *visible* bottom (= origin.y + height
// - inset.bottom, since the layout-baked inset.bottom isn't visible content)
// plus a 1pt spacing.
let streamingHeaderOffset: CGFloat
if let streamingTextFrame {
streamingHeaderOffset = streamingTextFrame.origin.y + streamingTextFrame.height - textInsets.bottom + streamingTextSpacing
} else {
streamingHeaderOffset = 0.0
}
if let streamingTextFrame {
// Mirrors TextBubble's suggestedBoundingWidth contribution at lines 886-893:
// visible_thinking_width + bubbleInsets.left + bubbleInsets.right
// where visible_thinking_width = streamingTextFrame.width - textInsets.left
// - textInsets.right. Adds 2pt for RichData's 1pt-per-side containerNode
// border that TextBubble doesn't have. Without this, an empty-pageLayout
// bubble was sized too narrow to fit the "Thinking" label.
let visibleThinkingWidth = streamingTextFrame.width - textInsets.left - textInsets.right
let thinkingMinBubbleWidth = visibleThinkingWidth + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + 2.0
boundingSize.width = max(boundingSize.width, thinkingMinBubbleWidth)
// Adds exactly the vertical space the streaming header consumes before the
// pageView starts (= where pageView's frame.origin.y will be set). Keeps
// the bubble's total height consistent with `containerHeight + closingPad + 2`
// computed in the apply closure.
boundingSize.height += streamingHeaderOffset
}
// The hardcoded "Thinking" header was removed in favor of server-sent
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
// header strip anymore, so the page content starts at the top of the bubble.
let streamingHeaderOffset: CGFloat = 0.0
if hasDraft {
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
@ -762,64 +686,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.currentRevealCostMap = nil
}
// 2. Update the "Thinking" header.
if let streamingTextFrame, let streamingTextLayoutAndApply {
var statusAnimation = animation
if self.streamingStatusTextNode == nil {
statusAnimation = .None
}
let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments(
context: item.context,
cache: item.controllerInteraction.presentationContext.animationCache,
renderer: item.controllerInteraction.presentationContext.animationRenderer,
placeholderColor: messageTheme.mediaPlaceholderColor,
attemptSynchronous: false,
textColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
applyArguments: InteractiveTextNode.ApplyArguments(
animation: statusAnimation,
spoilerTextColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji,
spoilerExpandRect: nil,
crossfadeContents: nil
)
))
let streamingStatusShimmerView: ShimmeringMaskView
if let current = self.streamingStatusShimmerView {
streamingStatusShimmerView = current
} else {
streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
self.streamingStatusShimmerView = streamingStatusShimmerView
self.containerNode.view.addSubview(streamingStatusShimmerView)
}
if streamingStatusTextNode !== self.streamingStatusTextNode {
self.streamingStatusTextNode?.textNode.view.removeFromSuperview()
self.streamingStatusTextNode = streamingStatusTextNode
streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view)
}
statusAnimation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
statusAnimation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
streamingStatusShimmerView.update(
size: streamingTextFrame.size,
containerWidth: streamingTextFrame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
} else if let streamingStatusShimmerView = self.streamingStatusShimmerView {
self.streamingStatusTextNode = nil
self.streamingStatusShimmerView = nil
animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in
streamingStatusShimmerView?.removeFromSuperview()
})
}
// 3. Drive the reveal controller.
// 2. Drive the reveal controller.
let previousAnimateGlyphCount: Int? = (hasDraft || hadDraft) ? (self.textRevealController?.currentGlyphCount ?? 0) : nil
if previousAnimateGlyphCount != nil || self.textRevealController != nil || hasDraft || hadDraft {
if hasDraft {

View file

@ -698,7 +698,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
))
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
if !"".isEmpty && (hasDraft || hadDraft) {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),

View file

@ -119,7 +119,9 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
)
let layout = layoutInstantPageV2(