diff --git a/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md b/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md new file mode 100644 index 0000000..5101000 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md @@ -0,0 +1,266 @@ +# Tweet Detail and Conversation Design + +**Date:** 2026-07-13 + +**Status:** Approved + +## Purpose + +Extend Twitter Lite with a read-only post detail page. A deliberate click on a +post, or a manually entered internal status URL, shows that focal post and the +visible conversation around it. Additional conversation pages load through the +same intentional infinite-scroll behavior as the existing user and search +feeds. + +This is an additive extension to +`2026-07-13-twitter-lite-design.md`. Where that document lists only two routes +and two server functions, this document adds one status route and one narrowly +scoped read-only server function. + +## Decisions + +- A thread means the full visible conversation: ancestors and replies from all + participants sharing the conversation ID, not only the author's self-replies. +- Bird remains unchanged. The existing `getTweet()` and `getThreadPaged()` + public methods are sufficient when composed through the conversation root. +- No new relay operation is required. Both reads use the existing `TweetDetail` + operation. +- Bird sorts the tweets within each fetched page chronologically. + Cursor pages are appended in retrieval order so already rendered content + does not jump. Preserving X's original ranked branch order is explicitly + deferred to a later Bird API investigation. +- The focal post is shown once at the top of the page. If the conversation page + also contains it, the copy in the conversation list is removed. +- The feature remains read-only and adds no history, recommendations, related + posts, author suggestions, or mutation controls. + +## Route and Navigation + +Add the typed route: + +```text +/status/:tweetId +``` + +`tweetId` is a non-empty decimal X post ID. Invalid IDs render a non-retryable +input error and make no relay request. + +Every non-quoted post card gains an internal `詳細・スレッド` link in its +footer. The existing external `元の投稿を開く` link remains available. The +whole card is not clickable because cards can contain text links and media +controls. Quoted cards retain their current compact presentation. + +The status route uses the existing application shell. Neither `ユーザー` nor +`検索` receives `aria-current` on this third route. The status page adds no +third global navigation tab; it is reachable only from a deliberate post link +or a manually entered URL. + +## Bird and Relay Boundary + +The application continues to construct one server-only `TwitterClient`. +`TWITTER_RELAY_BASE_URL`, `BIRD_PROFILE_NAME`, Bird, and raw GraphQL responses +must not enter the public browser bundle. + +Extend the application-local `BirdReader` interface with only the existing +Bird methods needed here: + +```ts +getTweet(tweetId: string): Promise + +getThreadPaged( + tweetId: string, + options: { + cursor?: string + maxPages: number + pageDelayMs: number + }, +): Promise +``` + +Add one TanStack Start GET server function, `loadThreadPosts`. This brings the +intentional server-function boundary from two functions to exactly three. No +generic Bird-method dispatcher or relay proxy is introduced. + +## Request and Result Contract + +The server input has two valid states: + +```ts +type InitialThreadInput = { + tweetId: string +} + +type ContinuedThreadInput = { + tweetId: string + conversationId: string + cursor: string +} +``` + +All IDs must be decimal strings. Cursor values are opaque non-empty strings. +`conversationId` and `cursor` must either both be absent or both be present. + +The successful page shape is: + +```ts +type ThreadPage = { + tweets: Post[] + focalPost?: Post + conversationId: string + nextCursor?: string +} +``` + +`focalPost` is present only on the initial page. All returned posts pass through +the existing public-post sanitizer, including recursive removal of `_raw` from +quoted posts. + +## Data Flow + +### Initial page + +1. Validate `tweetId` before creating a Bird request. +2. Call `getTweet(tweetId)` once. +3. If the post is missing or unavailable, return a non-retryable post error. +4. Set `conversationId` to `focalPost.conversationId ?? focalPost.id`. +5. Call `getThreadPaged(conversationId, { maxPages: 1, pageDelayMs: 0 })`. +6. Return the sanitized focal post, the first conversation page, its + conversation ID, and its next cursor. + +Using the conversation root for `getThreadPaged()` is important. It avoids the +existing Bird edge case where a stateless cursor request beginning from a reply +cannot rediscover the conversation root if the cursor page omits that reply. + +### Continued page + +1. Validate the supplied `tweetId`, `conversationId`, and cursor. +2. Call `getThreadPaged(conversationId, { + cursor, maxPages: 1, pageDelayMs: 0 })` once. +3. Return the sanitized tweets with the same conversation ID and new cursor. + +The original `tweetId` remains part of the query identity and validation +contract, but only the resolved conversation ID is used as Bird's focal ID for +continuation requests. + +## Client Query and Infinite Scrolling + +Extend the existing feed request union with: + +```ts +{ kind: 'thread'; tweetId: string } +``` + +The thread query key includes the focal tweet ID. Its initial page parameter is +undefined. A successful page with a next cursor produces the next page +parameter `{ cursor, conversationId }`, ensuring the resolved root survives the +stateless server boundary. + +Keep the existing intentional query policy: + +- no request without a valid route ID; +- no automatic retry; +- no refetch on window focus or reconnect; +- a 600px observer margin; +- one in-flight next-page request; +- explicit initial and later-page retry controls; +- cross-page post de-duplication by post ID; and +- continuation through an empty page when a new cursor is present. + +The focal post is taken from the first page and rendered above the conversation +feed. The flattened conversation removes any post with the focal ID, preventing +duplicate content. + +## Interface + +The page heading is `会話`. A short introduction states that only the selected +post and its conversation are shown. + +The focal card uses the existing post renderer and media behavior, with a quiet +`表示中の投稿` label and accent border. Its internal detail link is omitted to +avoid a link to the current page. The external X link remains. + +Below it, a `会話` section renders the available conversation posts using the +existing card presentation. Loading, empty, error, retry, and terminal states +reuse the established Mist Instrument language. Reduced-motion behavior remains +unchanged. + +## Error Handling + +- Invalid decimal tweet ID: non-retryable `invalid-input` error, no Bird call. +- Missing or unavailable focal post: non-retryable `post-not-found` or + `post-unavailable` message. +- Missing relay configuration: non-retryable `relay-config` message. +- Timeout or unexpected upstream failure: retryable error. +- Initial conversation failure: show the initial error state and refetch the + complete initial operation only after explicit Retry. +- Later-page failure: retain the focal post and all loaded conversation posts; + Retry requests only the failed cursor page. +- Empty conversation with no cursor: keep the focal post visible and show that + there are no additional conversation posts. + +Diagnostic details remain server-side. Browser-visible errors use the existing +small error union and never contain relay URLs, response bodies, or raw content. + +## Testing + +### Unit and component coverage + +- decimal tweet ID validation and invalid-input no-call behavior; +- initial `getTweet()` then root `getThreadPaged()` call order; +- continuation calls the root conversation ID exactly once; +- `maxPages: 1` and `pageDelayMs: 0` on every thread page; +- public-post sanitization for focal, conversation, and quoted posts; +- focal extraction and cross-page duplicate removal; +- thread query key and `{ cursor, conversationId }` page parameters; +- card internal link, external link, current-card label, and quoted-card + behavior; +- status-route wiring and neutral global navigation state; and +- existing user/search feed behavior remains unchanged. + +### Deterministic browser coverage + +Extend the local relay mock with GET `TweetDetail`, routed by operation suffix +rather than query ID. It validates the `e2e` profile, method, Bird variable +subset, feature object, and field-toggle object. Unsupported operations remain +non-404 and the mock makes no outbound requests. + +Run the new flows on desktop and mobile: + +- open a post detail page from a card; +- open a reply detail URL directly and resolve its conversation root; +- show the focal post exactly once; +- automatically load a second conversation page; +- retain prior content across a later-page failure and explicit retry; +- end without ambient discovery or mutation controls; +- preserve keyboard focus and reduced-motion behavior; +- report no browser console errors or uncaught page errors; and +- capture reviewed detail-page snapshots. + +The existing live smoke test remains one Top search. This feature does not add +an unconditional live `TweetDetail` read. + +### Final boundary checks + +- exactly three application server functions; +- no Bird/client or relay configuration identifiers in `.output/public`; +- no Bird mutation calls or generic relay proxy in application source; +- route generation, lint, typecheck, unit tests, production build, and all + desktop/mobile E2E flows pass; and +- both repositories remain clean except for their intentional local commits, + with no push or package publication. + +## Documentation Impact + +Update the original design's route and server-boundary lists, the implementation +plan, README feature and omission lists, browser test documentation, and the +expected server-function security count. `AGENTS.md` and `CLAUDE.md` remain +absent unless repository-specific agent instructions change. + +## Deferred Work + +- preserving TweetDetail's original ranked branch order; +- nested reply-tree rendering; +- author-only self-thread filtering; +- a dedicated status-ID input form; +- related or quoted-post discovery feeds; and +- any Bird API or relay-catalog change.