feat(templates): add the table-inbox page template - #5934
Conversation
A support triage queue beside a reading pane: the two-surface mail pattern the gallery did not have. The list is a real data-mode Table, so it keeps row status, selection and the plugin surface, and gives up only its dividers. The pane opens on the top thread at load and resizes against a list that starts at 480px. Each message is a collapsible headed by name, address and a date out at the chevron, newest pre-expanded; bodies start under the sender name rather than under the avatar. Attachments are thumbnails that open a zoomable lightbox. Reply and Reply all sit under the last message and open a non-modal bottom-sheet composer, already addressed, that hugs its own height and leaves the list live. Responsive in two steps rather than one: as the table narrows its rows stack sender over subject, keep the date right and drop the checkbox; narrower still, the pane takes the whole surface and the thread header stacks the back button above the subject. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR No new or modified components detected. Bundle Size SummaryNo component packages changed. Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
Every responsive decision in the inbox asked `matchMedia`, which only ever describes the viewport. That is the same thing as the template's own width exactly when the template owns the page — and the docsite preview does not give it the page. Rendered inline into a ~550px dialog on a 1512px window, the template drew full-fat columns into a third of the room it thought it had, and sized the pane from `window.innerWidth` so it covered the list rather than sitting beside it. The Layout root is measured instead, with a ResizeObserver and a synchronous first read before paint. `@container` would be the right question in the wrong instrument: its answer never leaves CSS, and what changes here is the column list, which only React can do. Falls out of it: `paneReserve` and its 40px quantum are gone. The threshold used to be "is the window under 560 plus whatever the pane is holding", a proxy for the table's width; now the table's width is just measured. The pane's default stops being window arithmetic and becomes a one-shot placement once there is a surface to subtract from. Also cuts the description from 1,570 characters to 390. The rubric caps a description that has become a catch-all at 1/3, naming ~490 characters as the line; this was the longest of 54 page templates by a factor of three. The four slots survive, the feature inventory does not. Co-authored-by: Cursor <cursoragent@cursor.com>
cixzhang
left a comment
There was a problem hiding this comment.
Thanks, the container-based reflow is working. One thing before this lands: after opening a thread, switching categories updates the table but leaves the old thread in the pane, so a triager can act on a conversation outside the selected queue. Please clear or re-derive chosenId when category changes (page.tsx:1890).
[Reviewed by Robohands]
Two tables that both let you tick rows and act on the set should not disagree about what that looks like, and table-filter got there first. The selection bar is now its band: the same muted Section, the same element radius, the same @starting-style rise on entry, and the same full-bleed run above the table header. Contents match too — ghost icon buttons on one side, "N selected", a separator, and Unselect All on the other. It stays a swap for the tab strip rather than a row stacked above it, so ticking a checkbox still moves the table by zero pixels, and it is pinned to the strip's height to keep that true. That makes it shorter than table-filter's, which is free to size to its own content. Alongside, from the same review pass: - The thread title is display-3 and wraps to two lines before truncating, with the tags pulled up under it and the message count dropped. A count of one is a label for something already visible. - The conversation pane holds a 24px content line on every edge. - Message bodies cap at 680px so a wide pane does not turn a paragraph into a single long line. - 8px between a collapsible's date and its chevron, which were touching. Co-authored-by: Cursor <cursoragent@cursor.com>
One conversation carried `isLocked`, which `getIsItemEnabled` turned into a disabled checkbox on the first row. As a rule it is defensible — a thread mid-escalation should not be swept up in a bulk archive — but it is not what a template is for. The first row is where anyone clicks first, and finding it inert reads as a broken table long before it reads as a policy. The state is gone rather than moved to a quieter row: every conversation is selectable now. The reading pane also pays an even 24px on all four sides. The top was 12px so the tags would sit close under the subject, but with the subject up in the header there is nothing there for them to be close to, so the tighter inset only made the pane look misaligned against its own edges. Co-authored-by: Cursor <cursoragent@cursor.com>
Switching category filtered the list but said nothing about the pane, so a thread opened from Escalations stayed on screen with Resolved in front of it — and the pane's own archive and delete would have acted on it. `openId` now falls back to the category's default whenever the chosen thread is not in the visible queue, so the two cannot disagree about which queue you are in. An explicit close still wins: `null` means the user shut the pane, and changing category is no reason to reopen it. The selection had the same hole. It is not reachable through the tabs, because the bulk bar takes the tab strip's place while a selection is held, but it is reachable by narrowing until rows stack: the bar hides, the tabs come back, and widening again re-armed ticks belonging to a category no longer on screen. `selectedCount` is now counted against the visible queue. The set itself is left alone, so switching back finds the selection where it was rather than silently dropped. Reported by @cixzhang. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Good catch — fixed in 42794cd, and it turned out to have a sibling. The pane. The selection had the same hole. It is not reachable through the tabs, because the bulk bar takes the tab strip's place while a selection is held — but it is reachable by narrowing until rows stack, which hides the bar and puts the tabs back:
|
The list opened at a flat 480px. That is a third of a 1440px page, which is where it was chosen, but well over half of the surface the docsite hands a preview — so the template that is meant to show a list beside a reading pane showed a list with a pane tacked on. The opening position is now two thirds to the pane, which holds wherever it is rendered. `LIST_MIN_WIDTH` gives the list a 300px floor, enforced as a ceiling on the pane because the list is not a sized region — it is whatever `LayoutContent` has left. Whichever binds first wins: the pane's own readable-line-length ceiling on wide surfaces, the list's floor on narrow ones. The pane's title drops to a plain h2. It was on the display scale, which is built for hero copy and renders at a size the docsite's type ramp does not use anywhere else, so beside the rest of the page it read as a different document. Co-authored-by: Cursor <cursoragent@cursor.com>
cixzhang
left a comment
There was a problem hiding this comment.
Thanks, the category/pane mismatch is fixed. One remaining recipient-safety issue: starting Reply on Dana after closing Priya’s unsent draft re-addresses the composer to Dana but keeps Priya’s body. A triager can send one customer’s message to another. Please scope preserved drafts to their conversation, or clear/confirm the body when a reply targets a different thread.
[Reviewed by Robohands]
|
Addressed in b71942c: reply drafts now carry their conversation identity, preserve an unsent body when Reply reopens the same thread, and clear it before changing recipients for a different thread. Added the required @astryxdesign/cli patch changeset; repository checks and focused lint passed. |
cixzhang
left a comment
There was a problem hiding this comment.
Thanks, the body now clears when Reply switches conversations, but the same draft still keeps Cc/Bcc state. I reproduced this by adding Lucas in Cc while replying to Priya, closing, then replying to Dana: the body is empty and Dana is To, but Lucas remains in Cc. A triager can still send one conversation's copied recipients into another. Please reset every recipient-specific draft field when the conversation changes, while preserving them when reopening the same conversation.
[Reviewed by Robohands]
|
Addressed in 7b85c17: switching conversations now clears Cc, Bcc, their expanded-field state, and the body before applying the new To/subject, while reopening the same conversation preserves the full draft. Focused lint, changeset validation, the core build, template-doc typecheck, and 13 focused template tests passed. |
cixzhang
left a comment
There was a problem hiding this comment.
Thanks, Cc/Bcc now settle correctly. One safety gap remains: the reset at page.tsx:1680 runs after the composer opens. Switching from Priya to Dana produced an initial frame with Priya, Marcus, Aiko, Priya’s subject, and “Draft for Priya only”; only afterward did it reset to Dana. A triager can see one customer’s draft while replying to another. Please make the new conversation’s first visible frame contain only its own recipients and subject with an empty body, while reopening the same conversation still preserves the draft.
[Reviewed by Robohands]
…ergeProps Three faults, all of them things the template got right on a page it owns and wrong everywhere it is embedded. The surface was measured two ways. The first read used getBoundingClientRect, which reports the box as painted, and the observer that followed it reported the box as laid out. Under an ancestor transform those disagree by the scale factor, and the template catalog draws every card through scale(0.5) — so the pane opened against half the real width. Both reads are offsetWidth-based now, which no ancestor transform touches. That halved figure then stuck, for two reasons that compounded. The pane was placed once and latched, so nothing corrected it; and the max it was clamped to had fallen below its own min, which the hook resolves by letting the max win, pinning the pane at 266px of a 1132px card. The placement now follows the surface until the handle is used, so an early measurement is no longer permanent, and the max is floored at the min so an inverted range cannot invert the result. The row actions were permanently visible in the Playground because the reveal reached the DOM through mergeProps, which the Playground's module scope does not provide — the call yielded nothing, the reveal class and both inline styles went with it, and content styled to fail visible did exactly that. HStack merges className and style itself, so the util was never needed here. Also drops the hover dwell to 90ms. It still buys the quiet it was there for during a sweep, without reading as the row being slow. Co-authored-by: Cursor <cursoragent@cursor.com>
…ar room The pane's ceiling was a flat 1040px, which stopped scaling the moment the screen got big: on a 1900px surface it pinned the list at 860px, so the stacked rows — which need it under 560 — could not be reached by dragging at all. The ceiling is now everything the surface has less the list's floor, so it tracks the screen and the list keeps its full range on any of them. Measured at 2400px: the list used to bottom out at 1360px and now goes to 316px, where it stacks. A share would not have fixed it, which is worth recording. Any constant share leaves the list a constant fraction, so its floor climbs with the screen and clears the stacking threshold again on a big enough one — at 80% the list bottoms out at 380px on a 1900px surface but 600px on a 3000px one. A pixel floor on the list is the scale-free version of the same intent. Nothing is lost by dropping the old ceiling either: it was justified by line length, and MESSAGE_MEASURE already caps the prose at 680px, so the pane around it can be whatever it needs to be. The bulk-selection band also grows: 8px above and below the buttons rather than 2, which stops a bar made of ghost buttons reading as a row of loose glyphs. Only the slot stays pinned to SELECTION_BAR_HEIGHT, since the slot is the part the table can feel — the band is bottom- aligned inside it so the extra 9px laps up into the header's padding instead of over the first row. Ticking a row still moves the table by 0. Co-authored-by: Cursor <cursoragent@cursor.com>
This is not a functional app so the comments are not relevant as a template
cixzhang
left a comment
There was a problem hiding this comment.
Thanks, the previous reply-draft leak is fixed. One responsive regression remains: after widening the pane with the separator, narrowing the preview leaves the panel at 1066px while its new maximum is 834px, so it overlaps the table instead of preserving the 300px list. Please clamp the selected pane size whenever the surface-derived maximum decreases (page.tsx:2116).
[Reviewed by Robohands]
This is a template PR so no reviews needed
…#6041) The table-inbox reading pane (#5934) computes maxSizePx itself from a plain ResizeObserver measurement (Math.max(paneFloor, surfaceWidth - listFloor)) rather than a containerRef percentage bound. A same-head review on #5934 found that after widening the pane past a later surface-derived maximum, the pane stayed oversized and overlapped the list instead of clamping down to the new ceiling. Investigation shows this exact literal-maxSizePx-shrink path is already covered by useResizable's general FR4 bounds-reclamp (landed in #5783, commit 311deef): re-resolving resolvedMax from a changed caller-supplied maxSizePx every render already re-clamps the committed selection, independent of whether the bound is a percentage or a plain number. Both a hook-level and a page.tsx-composition-level reproduction of the #5934 review's exact sequence (drag to 1066px, then narrow the derived max to 834px) already pass on current main. Add the regression directly to useResizable's FR4/FR6 suite so this caller-recomputed-pixel path can't silently regress again: one test pins the clamp itself and the ARIA-visible _maxSizePx/_size, the other pins that an in-range user choice is left untouched (AST-010 FR4: bounds clamp, they never scale). No runtime change: the fix is already on main.
What
A support triage queue beside a reading pane — the two-surface mail pattern the gallery did not have.
Table - Split Panewas an empty category until now.The list is a real data-mode
Table, not a stack of rows pretending to be one, so it keeps row status, selection and the whole plugin surface and gives up only its dividers.The parts worth looking at
Hover swaps the timestamp for actions. Not an overlay —
useContainerRevealwithposition: var(--_reveal-position)lets archive, move, delete and reply claim the cell, so the timestamp yields rather than getting painted over. The row height is pinned so nothing jumps.Every message is a collapsible, headed by name, address and a date out at the chevron, newest pre-expanded. Bodies start under the sender's name rather than under the avatar, which leaves the avatars hanging in a column of their own. Attachments are
Thumbnails that open a zoomableLightboxgallery.Selecting rows swaps the tab strip for
table-filter's bulk band rather than stacking a second row above it. Same muted fill, same@starting-stylerise on entry, same full-bleed run above the header — but pinned to the strip's height, so ticking the checkbox you just clicked does not move the table out from under the pointer. Clearing the selection puts the tabs straight back.Reply and Reply all sit under the last message and open the composer already addressed — Reply all gathering everyone in the thread except the answering team. The composer is a non-modal
BottomSheetthat hugs its own height, so the list stays live and clickable while you write. Placeholder-only fields, Cc and Bcc added on demand.Responsive to the surface, not the window. Every threshold here is measured off the
Layoutroot with aResizeObserver, not asked ofmatchMedia. That matters because a page template is not always given the page: the docsite preview renders templates inline into a dialog, and a viewport query there describes something the template cannot see. As the table narrows — by dragging the pane, or by the whole surface shrinking — rows stack sender over subject, keep the date right and drop the checkbox. Narrower still, the pane takes the whole surface and the thread header stacks the back button above the subject so it can run to two lines.@containeris the right question in the wrong instrument: its answer never leaves CSS, and what changes here is the column list, which only React can do.Remaining states — dark, full-width table, selection, single-surface list
Rubric score: 97/100 (A)
Rubric v1.3.
Iconor anicon={}propstylex.createblock, all justified — see belowLayoutroot, single page, responsive without fixed columnsThe description went through a correction worth flagging: it first shipped at 1,570 characters, which is three times the ~490-character case the rubric names as a catch-all and caps at 1/3, and the longest of 54 page templates by a wide margin. It is now 59 words / 390 characters carrying all four slots, which is what earns the 3.
| Image handling | 5/5 | Three
/template-assetsPNGs, all checked in || Code quality | 10/10 | |
These are the only custom CSS in the file:
ACTIONS_CLAIM_CELL(flexBasis: '100%',flexShrink: 0) —Stackhas no flex-basis prop andStackItemno width, and the hover actions have to take the cell geometrically rather than paint over the timestamp.HIDE_SQUEEZED_CONTENT(overflow: 'hidden') — clips the attachment clip as the cell narrows.isScrollablegivesauto, nothidden.styles.bulkBand/styles.bulkBandEnter— the selection band's radius and its@starting-styleentry. This one is not justified by absence of a prop but by precedent: it istable-filter's band, copied property for property, because two tables that both let you tick rows and act on the set should not disagree about what that looks like. A prop cannot express@starting-stylein any case, which is why the reference reached forstylextoo.I A/B'd the two templates side by side in one viewer rather than trusting the copy. Fill
rgb(241,241,241), radius 10px, transition 0.3s and the full-bleed run all match exactly. Height is the one deliberate divergence:table-filter's band sizes to its own content at 44px, while this one is pinned to the 37px tab strip it swaps with, so ticking a checkbox moves the table by zero pixels — verified across select, grow and clear.Worth flagging for whoever owns
Section: the radius is invisible in both templates.Sectionpaints its variant fill on an inner element whilexstylelands on the outer, so the corners stay square. I left it matching rather than "fixed" so the two do not drift; anoverflow: hiddenon the outer would make the radius real in both.If
Stackgains a flex-basis prop the first two go away.Depends on
#5933 — the
Collapsibletrigger-grow fix is what puts the message dates out by the chevron. This PR is mergeable without it; the dates just trail the address instead of reaching the edge. The screenshots above are taken with #5933 applied.Test plan
pnpm -F @astryxdesign/cli typecheck:strict— clean.eslinton the template — clean.Made with Cursor