feat(threads): Thread Panel — full side drawer (P3-8)

Right-side thread drawer (MembersDrawer pattern; mobile fullscreen):
- ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput
  (threadRootId prop; drafts/replies/uploads isolated per roomId::threadId;
  schedule + slash-commands off in threads v1) and threaded mark-as-read.
- ThreadTimeline: lean reimplementation over thread.liveTimeline — copied
  useTimelinePagination pattern (/relations back-pagination + decryption),
  virtualized, root event emphasized + "N replies" divider, reactions/edits/
  redactions, and a pending strip (chronological local echo never enters the
  thread timelineSet — rendered from LocalEchoUpdated instead).
- ThreadSummary chips on root messages (server-aggregated bundle or live
  Thread; unread badge via getThreadUnreadNotificationCount) keep threads
  discoverable now that replies leave the main timeline.
- Reply-in-Thread menu + thread indicators open the panel; deep links to
  thread events redirect into it.
- State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests).

Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Awaiting live QA; release note: threaded replies no longer render inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:45:20 -04:00
parent 15ac538a4b
commit aa62df9c75
16 changed files with 1811 additions and 54 deletions
@@ -0,0 +1,39 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const ThreadTimeline = style({
height: '100%',
position: 'relative',
});
export const ThreadTimelineContent = style({
minHeight: '100%',
padding: `${config.space.S400} 0`,
});
export const ThreadCentered = style({
height: '100%',
padding: config.space.S700,
});
export const RootMessage = style({
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
marginBottom: config.space.S100,
});
export const RepliesDivider = style({
padding: `${config.space.S200} ${config.space.S400}`,
});
export const NoReplies = style({
padding: config.space.S400,
});
export const PendingMessage = style({
opacity: 0.6,
});
export const PendingFailed = style({
opacity: 1,
});