postpile / desktop
CLAUDE.md0 starsChanged 17 days agoMITView on GitHub
Read these before editing anything under src/renderer/src/. The visual reference is the "Crisp native, refined" mockup (StyleCrispPro) with the "Warm reach" tile look on top (see "Tile look" below); the layout is still open, so keep components small and cheap to move. The renderer shows what the local API returned and nothing else. - Sample data lives in one place: FakeEngine (apps/server), switched on with POSTPILE_FAKE=1. The renderer never has its own fixtures and never has a fake ? sample…
The file
Show its rules and text
We split this CLAUDE.md into the rules it gives an agent
14 rules. About 0 are common, 4 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 3
Read these before editing anything under src/renderer/src/.
- Distinctive: line 18
Don't fill empty screens: No topics, no tiles, no glance, no reviews: render the empty state ("No topics yet…", "No glance yet…"), never …
- Very specific: line 26
When something is not wired yet (no endpoint, no data), render it disabled with a title that says why.
- Very specific: line 111
No OpenAPI codegen. When a screen needs a new field, add it to the core view type (views.ts), fill it in the engine's read-models.ts and in …
- Distinctive: line 206
No actions, no coral.
- Distinctive: line 207
After an action the provider invalidates every query except the config.
- Very specific: line 400
Don't pick a primary or decide a button's visibility in a component or in lib/.
- Very specific: line 411
Never write "stale" or "Sync to refresh" in the UI.
- Very specific: line 466
Every pane-level scroll area (sidebar, tile column, detail pane, chat, full-width panes) carries pane-scroll: it reserves the 10px …
- Distinctive: line 580
Don't extract a component that has more props than JSX children.
- Very specific: line 584
All icons are inline stroke SVGs in components/icons.tsx, 16px viewBox, stroke="currentColor", colored with text-* utilities.
- Very specific: line 686
Every PR in core's TileView.unreadPrKeys (what makes the tile unread: a thread unread on GitHub, a pulled-in layer's loud news, an unseen …
- Very specific: line 888
Every deliberate quit must go through shutdownOnce (or clear the marker, like the install fallback), else it counts as a crash.
- Very specific: line 917
Never use animation-fill-mode: both (or forwards) on an opacity or translate animation that stays on a settled element: the finished …
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
desktop renderer: rules that are easy to undo by accident
Read these before editing anything under src/renderer/src/. The visual
reference is the "Crisp native, refined" mockup (StyleCrispPro) with the
"Warm reach" tile look on top (see "Tile look" below); the layout is still
open, so keep components small and cheap to move.
No fictional data
The renderer shows what the local API returned and nothing else.
- Sample data lives in one place:
FakeEngine(apps/server), switched on withPOSTPILE_FAKE=1. The renderer never has its own fixtures and never has afake ? sample : realbranch. The title bar shows a "Sample data" pill when/api/configsaysfake: true. The one other difference:Avatarloads GitHub avatars only when the config saysfake: false, so sample logins (invented, but maybe real accounts) always show initials. - Don't fill empty screens. No topics, no tiles, no glance, no reviews: render the empty state ("No topics yet…", "No glance yet…"), never placeholder people, counts or repo names.
- Don't hardcode account-shaped values (repo, viewer login, PR counts, model names). If the UI needs one, add it to the API first.
Disabled, not hidden
When something is not wired yet (no endpoint, no data), render it disabled
with a title that says why. Hiding it makes the gap invisible to the next agent.
The same goes for a control that only the agent can serve (chat Send, Draft
with agent, Recheck): while the agent is off or paused, disable it with
useAgentOffReason() (api/tools.ts) as both the disabled test and the
title, instead of letting the click fail in a toast.
Data: typed query hooks, types from core
- One file per resource in
api/:topics.ts(useTopics,useTopic,useFinishedTopicsfor the sidebar's Archive drawer),pr.ts(usePr),chat.ts(useTopicChat, the agent pane),config.ts(useAppConfig),viewer.ts(useViewer, login, teammates and home teams for the filter buttons; no home team hides Team,visibleQueueFilters),team-roles.ts(useTeamRoles, home or routing only per team for "Your teams"; flips go throughactions.setTeamRole, rows areTeamRolesList, shared with the setup sweep step),proposals.ts(useProposals, the Suggestions pane),search.ts(useSearch, debounced title bar filter),instructions.ts(useInstructions,useInstructionsChat),lessons.ts(useLessons(topicId), a topic's open lessons; keylessons(topicId),lessonsAllrefreshes every topic's after a teach),sources.ts(useMemorySources, only enabled while a "Why?" panel is open),debug.ts(useDebugNotifications, the notifications debug pane),quiet.ts(useHandledQuietly, the "Handled quietly" list),writes.ts(useGitHubWrites, the footer lock),repos.ts(useRepos, the title bar repo menu),cleanup.ts(useInboxCleanup, polled every second while a cleanup runs),busy-inbox.ts(useBusyInbox, the busy inbox card's numbers; no timer, the live poll and actions refetch it),live.ts(useLivePoll: the fast poll status every 5s; called once in App, it refetches everything else when a poll cycle stored news, a glance catch-up run moved (catchUpChanges) or a sync started or ended;useLiveStatusis the same query without that effect,useNextAutoSyncAtfor "next full sync in N min"),sync.ts(useLastSyncReport, the stored last sync, whichuseActions().lastSyncfalls back to before this window's first sync;useSyncProgress, polled every second only while a sync runs, for the title bar'ssyncing · nothing new on GitHub · agent calls 34/82 · 2m, text fromlib/sync-progress.ts),setup.ts(useSetupStatus,useSetupChecks(runs gh and claude on the server, so only enabled on the checks screen; "Check again" is its refetch),useSetupSweep, polled every second while the job runs),interruptions.ts(useInterruptions: when PostPile may show a Mac notification, never / batches / asap, whether one was everchosen, plus the roundup times; read by the setup step "Your day", the sidebar's Interruptions menu and the one-timeInterruptionsPrompt; changed only throughuseActions().setInterruptions(mode, from), a PUT that shows the new mode right away and rolls back on failure, no toast;chosenflips only with the server's answer, and it returns whether it saved),tools.ts(useTools: gh and claude status with fix commands, every 30s while something is wrong, else every 5 min; "Check again" isuseActions().checkTools),update.ts(useUpdate: the server's last update check, every minute;lib/use-update-reminder.ts:useUpdateReminderasks core'supdateUrgencyfor none / pill / bar and core'supdateActionfor restart / downloading / command, from the main process's install state (preloadinstallStateandonInstallState; every change also refetches the update check; null on a plain web page, which means the brew command); "Later" is one shared snooze timestamp in localStorage, read byUpdatePillandUpdateBar),mcp.ts(useMcpConnection: is PostPile's MCP server in Claude Code, refetched on window focus; the server runsclaude mcp getat most every 5 minutes),telemetry.ts(sendTelemetry, fire-and-forget POST to/api/telemetry; not a query hook, no cache, a dropped call is swallowed. Only the events inRENDERER_TELEMETRY_EVENTS(@postpile/core) go through it — search, queue filter, topic and tile opens, setup steps and fit fixes, the update pill and bar; everything else is the engine's own.errorReportersends renderer errors on the same route asrenderer_exception:main.tsxhooks it to the window'serrorandunhandledrejectionevents,components/ErrorBoundary.tsxat the root to render errors. Raw error in, the engine scrubs it; logic inlib/error-report.ts). Each hook wrapsuseQuerywith a key fromapi/keys.ts. - QueryClient defaults (
main.tsx): no refetch on window focus and none on reconnect (refetchOnReconnect: false: after a wake the browser'sonlinerefetched every query at once against the local API),networkMode: 'always'for queries and mutations (the default paused them while macOS reported no network, but the API is on 127.0.0.1), one retry, 30sstaleTime. Data moves through invalidation (actions,useLivePoll), not through browser events; a hook that needs one sets it itself (useMcpConnection). - Wire types come from
@postpile/coreasimport typeonly. The renderer never imports runtime code from other workspace packages; small pure helpers live inlib/with tests next to them. - No OpenAPI codegen. When a screen needs a new field, add it to the core view
type (
views.ts), fill it in the engine'sread-models.tsand inFakeEngine, then read it here. PrDetail.pris core's slimPrPaneView(pr-pane.ts), never the storedPr: no comments, threads, commits or timeline, and no checks at all (PostPile does not fetch CI since 0.21.0). A new pane field goes intoprPaneView, which both engines call. "Pushed" readspr.lastCommitAt; comment text, Reply and Thumbs up come fromPrDetail.activity. Its rows are core'sActivityEvent(lines extend it with body,eventCount, the reply,thread,foldedandfold; a line with afoldis a quiet bot thread or a bot's review with its inline comments, drawn byFoldedLinewith no unread dot and no Reply): no raw events, and noPrDetail.eventseither. A row needs a new field: add it toactivityEventin core.- Derived UI values ("1 notified · 2 pulled in", check counts, review
rows) are pure functions in
lib/, unit tested. Components stay dumb. Rules are not display: facts (whose move, done, automation) and offers (which buttons, which leads, the lead PR) come from core as view fields, and tier order is typed with core'sPrTierOrder. A tile's group (TileView.group: unread, open, dealt_with) and the NEW pill (TileView.newBadge) come from core too; never comparestate.kindwithunread/doneor readunreadOnGitHub/automationhere.src/main/renderer-rules.test.tsgreps for that and fails. - Component tests are rare: only for wiring bugs a
lib/test cannot see (keys, remounts).*.test.tsxnext to the component, first line// @vitest-environment jsdom, views from@postpile/core/fixtures(seeDetailPane.test.tsx). - One key per PR, on
PrBody.DetailPanekeys the whole body by PR key. Don't also key its children by PR key: siblings with the same key leave stale copies in the DOM (0.15.1 showed one action bar per PR visited).
Mutations: one guarded ActionsProvider
- Every POST/DELETE goes through
useActions()fromapi/actions.tsx. Components never callrequest()for a mutation. - GitHub writes are guarded. approve, comment review, send comment,
reply, react, mark read (tile and PR-scoped), "not mine" (it queues a
mark-read), Mute and Unmute (
mute: the Snooze menu's "Mute until I'm mentioned" queues a mark-read and the GitHub unsubscribe, Unmute the subscribe; both run while locked like a mark-read and wait as pending writes; a muted tile isTileState.muted, says Muted and offers Unmute inSnoozeMenu) and "Remove<team>" pass throughwriteBlockedReasoninlib/guard.ts, which reads the footer lock (useGitHubWrites,GET /api/github-writes, changes at runtime). With the lock closed (read-only; on by default in the packaged app, locked by default in dev runs) approve and comment are blocked: their buttons (the pane's Approve and the ✨ Approve,agentApproveLook) are disabled with the lock reason as tooltip, anything else gets a clear toast (so does "Remove<team>",removeTeam: final, never a pending write); mark read and "not mine" still run but change nothing in the app: after the undo window they become pending writes (buttons carrymarkReadNote, the tile showsPendingWritePillfrompills.tsx, its Mark read button is disabled). Never show a locked mark-read as done. Everything is blocked until the writes state has loaded. Don't bypass the guard, and put a new GitHub-writing action on theGithubWritelist. The inbox cleanup (clearInbox, andclearSafeMergedfor the sidebar's "look safe" item) is on it ascleanupand behaves like mark read: locked, it becomes one pending write.startAsUsualis local (it only answers the start dialog) and shows no toast. - "Add to Claude Code" (
connectMcp(from)) changes Claude Code's config, never GitHub, so it is not on theGithubWritelist. Fire it only from a click, never from an effect or along with Accept: the app never installs the MCP server by itself.hideMcpConnectis the footer's "Not now". - The lock (
WritesLockin the footer, its popover state its own): locked = read-only. The footer is the only place to lock or unlock (2026-10-05, DESIGN.md "GitHub writes: lock, action log"): writes are on by default, so no banner, card, setup step, toast or title bar item offers to unlock. A blocked write may say in its tooltip or toast that the lock in the footer is closed; that is all. On, the lock is a faint open-lock icon (text-fainton purpose, hover shows it; a click locks); locked, a quiet "read-only" in the footer's owntext-muted, neveramber-*,status-bad, coral or honey. Opening it asks in a small popover ("Mark-read, approvals, comments and reactions will reach GitHub") that also lists the pending writes (lib/pending.ts) with "Send N to GitHub" / "Unlock and discard" / "Cancel" and "Discard, stay locked"; the "N pending" badge sits on the lock, also while writes are on (a failed send, or mark-reads from the locked days the default switch left). Closing it is instant unless something is pending. WithPOSTPILE_READ_ONLY=1it cannot unlock, only discard. The server keeps the choice and the pending writes; the renderer never stores them. States are checked inWritesLock.test.tsx. - Buttons for guarded actions carry the blocked reason as their
title. - The notifications debug pane has "Mark read" (thread level, same queue,
undo, lock and action log as a tile). There is no "bring back": GitHub has
no mark-unread, and the app never holds a read state GitHub doesn't have. Each row shows its last action log entry
(
actionLineinlib/notifications.ts); a read thread without one reads as "read on github.com or another client"; a quiet mark-read (originquiet) as "marked read by PostPile: only bot activity since your last read", or with its reason ("you approved after it", "opened in PostPile"). Rows also show the newest ping decision (pingDecisionLine), the expanded row the last three. Keep the tooltips honest about what reaches GitHub. - "Handled quietly" (
HandledQuietlyPane, panequiet) is read only: the engine's own quiet mark-reads of the last 7 days with their reason (quietReasonTextinlib/quiet.ts), a click opens the tile. No actions, no coral. - After an action the provider invalidates every query except the config;
the action's busy key lasts until that refetch lands. Approve, mark read
(tile and PR) and snooze change the cache on click first (
run'soptimistic, next state fromlib/optimistic.ts, only from shipped fields likeafterRead) and roll back on failure; nothing early while locked or blocked. Mark-read and memory correction results carry an undo token; the toast offers Undo for the 6s window and the footer counts mark-reads in the undo window. So do the local "take back" choices: a Suggestions Decline (proposal:,rule:) and Remove on an accepted rule or a tailoring line (rule:,tailoring:). Those andmemory:tokens are not mark-reads (isMarkReadUndoTokeninlib/mark-read.ts). When a window ends the provider refetches, so a locked mark-read shows up as pending. - Big memory lines get "Recheck" (and "Forget" on a care):
MemoryLinetakescanRecheck(dossier status, goal, open questions, people, cares; facts bycanRecheckFact:recheckableor stale), never change lines or activity. The glance's "Recheck" (on its title line, next to "Tell the agent") opensRecheckDialogfor the whole glance (prKeyset, no Accept; "Mark out of date" on a drop, else Tell the agent / Close). The dialog's title comes fromrecheckTitle. Recheck opensRecheckDialog:recheckMemoryruns one agent call and writes nothing; the user then accepts the outcome throughcorrectMemory(holds ->confirm, fix ->fixwithfixedText, drop ->wrong) or picks "Tell the agent what's wrong", which opens the topic's agent pane with the line quoted (TellAgentContext,AgentPane). Corrections only touch local memory, so they are not on theGithubWritelist, and carry an undo token (toast Undo, 6s). A fact changes right away; a dossier line shows struck through (correctedClaims) or with its fix (fixedClaims) until the next sync rewrites the dossier, with Restore (restoreMemoryLine) all that time; a line kept through Recheck (confirmedClaims) passeskeptand drops its stale badge. Never firerecheckMemoryfrom an effect without a guard: StrictMode would double the agent call. - Instructions writes (
instructionsChat,proposeInstructions,saveInstructions) are local (instructions.md + SQLite), not on theGithubWritelist. The user's instructions are never changed without an Accept on a proposal; the card shows a line diff (lib/diff.ts), Edit and Reject. In the agent pane the user places a lasting point with three buttons (Keep for this topic / Just this once / Keep for all topics) and a short line confirms the pick; left alone it is just this once; the agent never picks the scope. An open instructions diff outlives its pane or page in a module-level holder (waitingInstructionsinAgentPane,waitingProposalinInstructionsChat) until Accept or Reject. An agent answer for a topic whose pane is not on screen shows a toast with Open (showAgentAnswered, NoticeopenAgentTopicId). A save that comes back withrebased(the file changed on disk meanwhile) replaces the card's proposal, it is not an error to swallow. - Lessons (DESIGN.md "Lessons from your reviews") are local, not on the
GithubWritelist.keepLessonForTopicanddismissLessongo throughrun(toast, refetch of everything: lessons, tailoring, instructions).proposeInstructionsFromLessonwrites nothing: its proposal shows in anInstructionsProposalCardunder the lesson and saves throughsaveInstructionslike a chat one (accepting closes the lesson); a reply without a proposal is shown there as text, not a toast.teachLessonis one agent call; its reply (nothing reusable, agent off, failed call) is shown under the glance. Fire both only from a click. Proposal cards are keyed withproposalKey(lib/instructions.ts): a proposal has a chat message or a lesson as its source, never both. - Topic proposals come from consolidation or, through the MCP server, from
an outside agent (
source: 'agent',client). Say who suggested an outside one wherever it shows: the Suggestions card's meta line (proposalMeta: "topic · suggested by Claude Code · 2h ago", plus the agent's session name before the age when it gave one) and the topic header row ("Claude Code suggests: …"), both fromsuggestedByinlib/proposals.ts(unknown clients read "an outside agent"). Accept can come back refused when the proposal no longer fits; show the message, never retry. - Setup (
startSetupSweep,refineSetup,acceptSetup,skipSetup) is local, not on theGithubWritelist. Accept also carries the "Your day" pick (interruptions, null leaves the stored mode); the engine sendsinterruptions_changed, never the renderer. Accept is the only write and it writes a new instructions version; a result withcurrentmeans the file changed meanwhile: go back to review with that as the base, never retry blindly.refineSetupis one agent call and writes nothing; fire it only from a click. - "What you're working on" (
WorkContextSection, bottom of the instructions pane) is agent-written from local Claude Code notes and stays visibly apart from the user's instructions (dashed frame, "agent-written"). It is steered by Forget (forgetWorkThread, local, undo token with thememory:prefix) and Refresh (refreshWorkContext, one agent call); never add an edit box. - Agent-derived memory is steered only by chat, Recheck and Forget, never by
editing its text. Every memory line takes a
whytarget (lib/sources.ts:lineTarget,changePath) and shows "Why?". markTopicSeenis quiet (no toast).App.tsxcalls it when the user leaves a topic (another topic or Suggestions), not on a timer.markOpenedReadhas no toast and is on theGithubWritelist asopenedRead, blocked while locked.useOpenedReadinApp.tsxcalls it once per open, when the PR has stayed 1.5s in the detail pane with the window visible and focused (OpenedReadTimer: the dwell end fires; hidden before that restarts the wait, after it changes nothing). Only whenopensMarkRead(lib/opened-read.ts) says a mark-read of that PR leaves nothing asked of the user (core'sPrSummary.openedRead, fromafterRead.nothingAsked, per PR, not the tile's; a PR the user's change request holds open qualifies though it never turns done, 2026-10-12). The server checks again and marks it read like the pane's Mark read (its own batch, undo token inOpenedReadResult). The pane then showsOpenedMarkNote("✓ Marked read" / "✓ Done for now") in the mark button's place with Undo while the window lasts (UNDO_WINDOW_MSinlib/undo-window.ts); Undo goes throughuseActions().undoand the open does not arm again. Never a "Marks read when you leave" promise (2026-10-01, DESIGN.md "Marked when the dwell ends"). Since 2026-10-10:App.tsxpasses only the user's own pick (dwellPrKeyinlib/selection.ts, fromuserPick, which everygo()clears andopenByUsersets), never an app-picked tile. Since 2026-10-12 a topic click (openTopicByUser) counts as picking the tile it opens on: it waits inclickedTopicIduntiltopicClickOutcomesays the topic's tiles are in, then setsuserPickwithfromTopicClick. Open a topic for the user throughopenTopicByUser, not a barego();useTileVisitfollows the same rule (pickedByUser). A mark that turns wanted after the dwell fires only afterOpenedReadTimer.userActed()(the hook calls it while the pane's Approve, comment review or Remove team is busy). Any openMenuholds the dwell (lib/open-menus.ts): build popover menus onMenuso they get this. The note goes once the undo window is over and the PR is unread again (openedNoteShows).- The selected tile and its topic row hold their place (
useHeldPlace) until the selection moves; list moves then slide withuseFlip(lib/use-flip.ts): mark the moving elementsdata-flip-key(never one inside another) anddata-flip-group. The unread dot (UnreadDotshown) stays mounted; hiding it shrinks it and plays one ripple. A dot that mounts hidden ripples only when itsdotKey(tile:/detail:+ PR key,topic:+ id) was shown within the last second (RecentDots,lib/recent-dots.ts: a row that remounts on read). In tile rows it takescountdownfromdotCountdown(lib/opened-read.ts) and drains as a pie over the dwell (.unread-pieinapp.css, 2026-10-06). Respectprefers-reduced-motion(motion-reduce:or the hook's check) in any new motion. - The settle after a read on the held tile (2026-10-07, DESIGN.md "Marked
when the dwell ends" › The settle after the read) is plain CSS with small
flags:
useSettling(lib/use-settling.ts) is true for a moment after a value turned on while mounted, never on first render;Foldopens and closes content by height (one-row grid,1fr↔0fr, spacing inside the content, closing keeps the last open content);Crossfadeswaps content in one grid cell with enter / leave keyframes fromapp.css. Group headings readheadingGroup(lib/queues.ts). "Archive now" flies the sidebar row withflyToArchive(lib/archive-flight.ts) whileLeavingRowcloses its space. Reduced motion:reducedMotion()inlib/motion.ts; everything lands at once. Don't grow these into a generic animation layer. - A missing glance is worded from
PrSummary.glanceState/PrDetail.glanceStatethroughglanceStateText(lib/glance.ts), never "the next sync picks it up". Only a failed glance gets a button: Retry (useActions().retryGlance, local, not on theGithubWritelist). No manual refresh per PR or topic (decided 2026-09-29). Refresh on look is automatic, not a button (2026-10-01):useGlanceLookinDetailPaneasksuseActions().refreshGlanceOnLookonce per open after the 1.5s dwell when the glance is stale; the server decides, and the words come fromglanceStateandglanceRefreshBlock. Its timer (lib/glance-look.ts) is its own, apart from the opened mark's. - Approve is final (GitHub has no un-approve). Keep it a deliberate click in
the detail pane, in the review row right after the glance (DESIGN.md
"The PR pane", 2026-10-05): its own row with a GitHub review-state label
(
reviewRowLabelover core'sPrDetail.viewerReview), never inside the glance, same order on every PR. - The pane's writes share one inline
Composer(useComposeState(prKey)inPrBodythroughComposeProvider, one open at a time). Drafts, agent drafts and the open composer live inlib/pane-drafts.ts(PaneDrafts, one app-wide store likeAgentPane's unsent text) keyed by PR and target, so they survive the per-PR remount and can never be read for another PR. The store is the only copy of the text and the open target (read withuseSyncExternalStore, never copied into state): a send that ends after the user left and came back must empty and close the composer now on screen, or the posted text could go out twice (Codex on #176). A composer that comes back with its PR (reopened) takes no focus and does not scroll. Escape and Cancel focus thearia-expandedbutton next to it; Meta/Ctrl+Enter sends everything but Approve with a note. Write failures are worded bylib/write-failure.ts(plain words, raw line as the toast'sdetail). The composer has a header that says where it goes, one box with the agent's pill where the text starts ("✨ Draft with agent" / "✨ Rewrite with agent"), Cancel and a button that names the target. Only the review notes draft by themselves (draftsOnOpen, 2026-10-06): once per opening, into an empty box, not while the write is blocked oruseToolssaysagentOn: false; it asks withquiet(no error toast, a clicked draft keeps its toast); a ref guards StrictMode's second effect run.sendgets the note's source (lib/review-note.ts) for telemetry. Replies live in the activity list (core'sActivityLine.reply, once per comment); "New since" only jumps there ("Reply ↓",jumpToReply). Housekeeping (PaneHousekeeping) is a quiet line right under the review row, never inDetailContext(that band is the stack/set PR picker); "Open on GitHub" (OpenOnGitHub) only on the state line. - The agent pane (
AgentPane, App'sagentRequest) replaces the detail pane for the topic on screen;go()clears it, so any pick hands the column back to the PR. Never put agent talk inside the PR pane. - The detail pane acts on the selected PR, the tile footer on the tile
(2026-09-29). What each button says, whether it shows and which one leads
come from core (
TileView.offers:footer,markLabel,github,leadPrKey,notMine(the ⋯ menu's "Not mine", left out while the tile reads Not yours),unmuteRest(the Snooze menu's "Unmute the rest" on a partly muted stack or set), andpane[prKey]withscope,lead,approve,open,ask,markLabel,snooze,removeTeams,pendingWrite). On a stack or set (scope: 'pr') the mark button marks only that PR (useActions().markPrRead, own undo, toast without the Snooze offer) and Snooze is not in the pane. A done tile or PR offers Open on GitHub only. Don't pick a primary or decide a button's visibility in a component or inlib/; add it to core's offers. Approve's label and look (approveButton) stay display. - "Remove
<team>" (one item perPaneOffers.removeTeamsentry viaremoveTeamButtonsinlib/team-request.ts, inPaneHousekeeping's ⋯ menu) removes the team's review request, unsubscribes and marks the PR done. It asks once in the menu's panel and has no undo. Never the primary. - "Not up to date" has one wording (
lib/staleness.ts): "updating" whileuseActions().syncingor a catch-up writes (glanceStatewritingfor a glance;memoryUpdatingon the topic or PR for dossier and facts, since a glance-only refresh rewrites no memory), else "out of date". Never write "stale" or "Sync to refresh" in the UI.MemoryLineandWhyPaneltakeupdatingfrom their caller (topic or PR state viaupdatingNow); don't readsyncingalone there. A stale glance showsStaleVerdictBox(grey, dashed) with the advice folded behind "Show old assessment".
Styling: tokens + Tailwind utilities
- Tokens live in
styles/tokens.csson:root: every color, radius, shadow and font.styles/app.cssmaps them onto Tailwind names in@theme(bg-surface,text-muted,border-hairline,bg-unread-soft,rounded-tile,shadow-selected,font-mono, …). - Small grey text:
text-hint(#666b79, at least 4.5:1 on white and the sidebar) for small text that says something (why-here lines, out-of-date notes, "Dossier v3", roles, fold labels, empty states, hover actions).text-faint(2.3-2.6:1) is decoration only: separators, chevrons, ages next to a louder line, done tiles. - Diff red:
--diff-redfor deletions in the Size fact. Coral (unread) is never a diff colour. There is no CI anywhere (DESIGN.md "CI is not tracked"). - One colour per meaning (2026-10-01, DESIGN.md "Colour per
meaning"):
closeronly for the agent's Look closer;status-badthe one red for bad (closed, changes requested, risk, errors);safethe one good green (approved, Looks safe, Approve); merged purple also for queued; coral only for unread; honey only for aimed at you and your move; app-health warnings useamber-*. - Tailwind's default palette is switched off (
--color-*: initial). Only token colors exist as utilities. Need a new color? Add a token totokens.cssand a--color-*line to@theme, don't reach for hex in a component. - Utilities first, inline
styleonly for render-time values (e.g. the flex weights of the size and checks bars). Comment why when you do it. - Hover, focus and selected states are utilities or props-driven class
strings, never
onMouseEnterhandlers. - Type sizes follow the mockup with arbitrary values (
text-[11.5px]); spacing uses the scale where it is close enough. The root font size is 13px, soapp.csspins--spacing,text-xs,text-lgand the radius steps to px:px-3is 12px,h-728px, as the mockups read them. - Edges on white chips, secondary buttons, rows and boxes are inset rings
(
inset-ring inset-ring-edge-*, tokens--ring-*), not borders: no layout shift, crisp on tints.shadow-tilecarries a card's hairline ring. The tile is the exception: every tile frame is a 1px border (resting:border-edge-hairline bg-clip-padding shadow-tile-lift, the ring's look), so selection only changes colors and moves nothing. - One-line labels that the mockups size by their text get
leading-[normal]; the inherited 1.5 from preflight makes them taller. - The detail pane keeps three keylines from its edge: boxes and rows at
22px (pane padding), line starts at 34 (
px-3), text after an icon at 62 (icon centered in a 20px slot, 8px gap). Section labels useSectionLabel. - Numbers, PR numbers, ids and ages use
font-mono(JetBrains Mono, bundled instyles/fonts/, no network). UI text uses the system font. - Plain CSS in
app.cssonly for things Tailwind can't say well: the.drag-regionfor the title bar, basehtml/bodyrules,.pane-scroll,.unread-pie(a conic gradient over the registered--sweepangle). - Every pane-level scroll area (sidebar, tile column, detail pane, chat,
full-width panes) carries
pane-scroll: it reserves the 10px scrollbar gutter at all times, so a pane does not jump sideways when its content starts or stops overflowing. Small inner lists (pickers, menus, diffs) do not need it.
Missing tools (gh, claude)
The server says what works (GET /api/tools, ToolsView with headline,
detail and fix commands); lib/tools.ts only picks where it shows, and
ToolsNotice renders it. Never write your own wording for a tool state.
- gh missing, logged out or refused: with no topics the note is the middle
column's empty state (
place="empty"), otherwise a banner above the topics. The Sync button is disabled with the reason astitle, the title bar says "sync off", and the start sync is skipped quietly. - claude off or at its limit: one "Rules only" / "Paused" line above the topics with a "How to fix" fold. Agent actions still show their own error toast.
- gh offline only shows in the footer ("GitHub unreachable"); the poll backs off by itself.
- Check with
POSTPILE_FAKE_MISSING=gh|gh-auth|gh-token|gh-offline|claude|claude-auth|claude-limit.
Components: small files, extract for behavior
- One component per file in
components/, named like the UI part:TitleBar,TopicSidebar,TopicHeader(+SinceLastLooked,DossierPanel,TopicRepoon the owner line),InboxPane,TileGrid,Tile,PrRow(+AssignedTo, also inPrBody),NotificationsPane(+NotificationRow),HandledQuietlyPane,DetailPane(+DetailContext,PrBodywithOpenOnGitHub,GlanceCard,ReviewRow,PaneHousekeeping,Composer,KeyFiles,AgentNoteLines(an outside agent's note under the header, muted, quiet Clear throughuseActions().clearPrNote, local; words inlib/agent-notes.ts),PrDescription,PrFacts,ReviewList,NewSinceBox(the digest under the title, "Reply ↓" jumps),AgentFacts,ActivityTimeline(every line, Reply and React on people's comments,FoldedLinefor replies to bots and bot reviews; a row's age isAgeLink, a plaintarget="_blank"link toActivityEvent.urlthat main opens in the browser, plain text without one)),AgentPane(the topic's agent, in the detail pane's place),StatusFooter(+WritesLock),Toast,SearchField(title bar filter),ToolsNotice(missing gh or claude, withFixCommand, shared with setup),RepoScopeMenu(title bar repo scope + "Let it go stale"),InboxCleanupLine(sidebar footer: "12 merged PRs · Clean up…", "Clearing 84 / 191" while a run goes; under merged PRs, on its own line, "✨ 8 of them look safe · Clear these 8",safeMergedText, which callsclearSafeMergedright away, no dialog, disabled while a cleanup waits in the lock) +InboxStartDialog(mounted once in App, opens while the server holds the start sync,view.start) +InboxCleanupDialog(both modes; picks set once from corecleanupDialogSetup; words inlib/cleanup.ts; Esc, Enter and a click outside follow the case; the illustration bobs only undermotion-safe:). The footer shows the run's progress, and the done toast's "Show" opens the notifications view (ToastproponShowActionLog),BusyInboxCard(sidebar, after Suggestions and right above the topics, only whileBusyInboxView.busy: the aching robotBusyInboxArton thebusy-*keyframes, only undermotion-safe:; calmamber-*tokens, never coral or honey, the sweat drop--dropthe one blue; words and per-tier counts inlib/busy-inbox.ts. Clean up opensInboxCleanupDialogin sidebar mode, Why? folds out inline. No writes lock and no writes line here: the lock lives in the footer only. It folds to one line for the session, sessionStoragepostpile.busyInbox.folded; wiring test inBusyInboxCard.test.tsx),UpdatePill(title bar update reminder, self-contained so it can move; neutral, never coral; under 24h behind; "Update ready" once staged) +UpdateNextStep(the offer both share: ink "Restart to update", the download note, or the brew commandFixCommand) +UpdateBar(full-width bar under the title bar from 24h behind, amber--amber-*tokens, never coral; mounted inApp.tsx; Later drops back to the pill for 24h;update_bar_shownonce per run),InterruptionsMenu(sidebar footer row after "Handled quietly": bell-off icon for Never, bell otherwise, the mode as a quiet word; opens a menu above it with the three modes as menuitemradio rows and "Send a test notification", disabled outside the desktop app; the bell means only interruptions, notification lists useListIcon),InterruptionsChoice(the three mode cards withInterruptionsArt, shared bySetupDayStepand the prompt) +InterruptionsPrompt(mounted once in App: the one-time dialog for installs that never chose,InterruptionsView.chosenfalse,showsInterruptionsPromptinlib/interruptions.ts; blocked while setup shows or its status loads, and while the inbox cleanup view loads or its start dialog is due; Save stores the pick, Esc and a click outside store the current mode, both throughsetInterruptions(mode, 'prompt'); it closes only once that returns true, a failed save keeps it open with Save usable again; wiring test inInterruptionsPrompt.test.tsx),McpFooterItem("outside agents: not connected" in the footer, only whilemcpFooterShowsinlib/mcp.ts; never while the state is unknown) +McpConnectOffer(the offer body, shared withSetupAcceptStep's optional box; secondary button there so Accept stays the one primary). - Shared kit:
Button(variants primary, safe, secondary, move for the "Your move" footer, quiet for the pane's housekeeping; sizes sm, md, icon, icon-md),Menu,Avatar,SectionLabel,pills.tsx(verdict,UnreadDot(the coral dot before a PR number and on a topic, coreTileView.unreadPrKeys),ForWhomChip,StateWordLabel,StackMark: the "1/3" layers tag, place fromstackPlacesinlib/stacks.tsovertile.stacks),icons.tsx(Glyphevent set,PrStateIcon),TurnLine, and for memoryMemoryLine(text, source chips, stale / checked-by-you / forgotten / fixed note, Restore on a struck line, Why? / Recheck / Forget on hover),MemoryButton("Forget"),RecheckDialog,SourceChip,WhyPanel+MemorySourceRow("Why?"),DiffView,InstructionsProposalCard(the agent pane, the instructions view and under a lesson;onDone(accepted)),LessonCard(one lesson: "Remember for future assessments?", the line,lessonSource/earlierAssessmentTextfromlib/lessons.ts, then Remember in this topic / Use across topics… / Dismiss; neutral grey, never coral, honey or accent), used byTopicLessons(under the topic header's "Kept for this topic" lines,TopicTailoring, headerTAILORING_HEADER: one line per entry, split bylib/tailoring.ts, each with Remove;TopicLessonsrenders nothing without open lessons) andTeachLesson("Teach future assessments" under the verdict explanation inGlanceCard, only with a glance; keyed by PR; hides its lesson once it left the topic's open list),WorkContextSection(instructions pane only),AcceptedRules(instructions pane: accepted rules for every topic with Remove),ProposalPrList(the PRs a split or move takes along,proposalPrsinlib/proposals.ts; Suggestions card and topic header row),RelationBadge(inpills.tsx). Something used in three places goes here; two call sites can stay duplicated. - Don't extract a component that has more props than JSX children.
Icons
- All icons are inline stroke SVGs in
components/icons.tsx, 16px viewBox,stroke="currentColor", colored withtext-*utilities. No icon font, no icon package, no emoji. Shapes follow GitHub's Octicons, drawn simply, so PR states read like on github.com. - One-path glyphs go in
GLYPH_PATHS(Glyph); anything with a fill or a dash pattern gets its own small component (PrStateIcondraft,RingDotIcon). - PR state:
PrStateIconwith core'sPrStatus.icon(open green pull request, draft dashed grey circle, merged purple, closed red, the filled Octicons merge-queue icon in--pendingamber while queued and red once the queue took it out; the word fromICON_WORDSas its title). A queued PR's row showsmergeQueueWord("Merge queue: Testing") in place of the review word, and so do the open layers below a queued layer of the same stack (stackQueueWord, "Merge queue: with 3/3"): the top branch holds their commits, so they merge with it. Review state:StateWordLabelwith aStateWordfromreviewWord/rowStateWord(lib/pr.ts). Never a CI icon or word: PostPile has no CI data. - Icons carry words: a lone icon gets a
title(andaria-labelwhen it is the only content of a control).
Focus
app.css drops the ring for mouse focus (:focus:not(:focus-visible)) and
draws one accent ring with an offset for keyboard focus on buttons and
links. Rows inside a rounded box use focus-visible:-outline-offset-2 so
the box cannot clip the ring. Back / forward (useNavShortcuts) blur the
focused element of the old view; without that, a clicked row kept focus
and showed Chromium's ring after the next key press, on a tile that was no
longer selected.
- Order functions so they are defined before they are used.
Tile look ("Warm reach")
Four spots per tile, all derived in core and shipped on TileView /
PrSummary (DESIGN.md "Tile faces"); the renderer only picks labels and
tints (lib/why.ts, lib/events.ts, reviewWord / rowStateWord in lib/pr.ts).
- For whom:
ForWhomChip("For you" honey, "For team-devex" sea, "For approvers" neutral for a routing team, "Your PR" neutral, nothing else) fromTileView.forWhom/PrSummary.forWhom, plus a 3px left band on the tile in the same color (BANDSinTile.tsx; a routing team gets no band, sea means your team); PR rows get the small chip, no band. The tooltip keeps the long why-here reason (whyTitle). Grey on done tiles. - Scout tiles (
TileView.scout, DESIGN.md "Scouts"): a neutral grey chip with the scout's name takes the for-whom spot, no left band; the scout's reason replaces the glance reason line; never coral, honey or sea. Their footer isScoutTileFooter(Follow, Dismiss), never Mark read, Snooze or Approve: nothing is asked of the user there. The sidebar's Scouted section (ScoutedSection,api/scouts.ts) sits last with a grey unseen count; opening a scouted PR marks it seen (lib/use-scout-seen.ts), not read. It shows with no scouts too: its header carries the still searchlight (ScoutIntroArtsidebar) and its last row, "Set up a scout", opens the intro card. - The Scouts intro card (DESIGN.md "Scouts" › The intro card):
ScoutIntro(mounted once in App) decides when,ScoutIntroCardholds the prompt, repos and both screens,ScoutRepoPickerthe "Watch" chips,ScoutCheckScreenthe result after "Check scout",ScoutIntroArtthe searchlight on thescout-*keyframes (only undermotion-safe:; with Reduce Motion the found PR shows lit). Rules inlib/scout-intro.ts. It opens by itself whileuseScoutIntro().autoShow, never on top of setup, the inbox start dialog or the unanswered interruptions question; once open it stays mounted (hidden while such an overlay turns up) so typing survives. The draft runs once per opening (a ref guards StrictMode) and never overwrites typing (a failed one says so in the card, with a Retry that keeps typing); while it shows, the card holds the open-read dwell like a menu (holdMenuOpen), so the PR behind it is never marked read; draft and check write nothing and show failures in the card, not a toast; Turn on and Not now go throughrun(toast, refetch). Path hints go along to Turn on and show only in a tightening's before and after.ScoutTrialSectionholds the dry run: examples and near misses with Want / Noise word buttons and the why field, Tighten, and the tightened box with Undo. Labels and the version stack live inScoutIntroCardstate; the rules are inlib/scout-tighten.ts. Turn on writes the newest version. - Why now:
UnreadStrip, warm strip, actor avatar with an ink event badge (Glyph), a coral "NEW" pill (only whileTileView.newBadge), age. On a revisit (PrSummary.whatsNewof the strip's PR,stripNews) the text, avatar, badge, "+N" and age come from core'swhatsNew, worded bywhatsNewTextinlib/whats-new.ts; never add a second line or change the strip's size. Only tiles in the Unread group have the warm strip; an open tile with an unseen merge without the user's review (TileState.unseenMerges) gets the same-sized greyUnseenMergeStrip, no NEW pill; read tiles get a quieter (ink-2) title, Dealt with and draft tiles a muted one. Drafts (core'sTileView.draft,isDraftTile): grey "Draft" chip and a dashed frame or dashed left band. - PR status (design 3a, 2026-09-29):
PrStateIconat the start of the row,StateWordLabelon the right ("Needs review", "Approved", "Changes requested", DRAFT chip, "Merged", "Closed"), open threads after it. The state keeps its color on done tiles; title and counts go grey. When only agents approved (PrStatus.agentApprovers) the word reads "Approved by agent", names in the tooltip; the detail usesPrDetail.agentApproverswithapprovedTextinlib/pr.ts. A stamphog decline (PrStatus.agentDecline/PrDetail.agentDecline) reads "stamphog declined" in the review color while no person approved or asked for changes, why in the tooltip (declinedText); never a warning color. No CI anywhere: PostPile fetches no checks (DESIGN.md "CI is not tracked";PrStatusandPrPaneViewhave none). - PR rows: a single-PR tile's row has no title (
PrRowshowTitlefalse; the heading is the title). The author's avatar is who opened it (PrSummary.author, a bot for agent PRs); when someone else is assigned,AssignedTofollows ("assigned to" + faces, two then "+N", fromassigneeLineinlib/assignees.ts), andPrBodysays "opened by · assigned to" under the branch line. Whose PR it is for rules is core's (authorRelation,facts.owners: "Ask<owner>"), never decided here. Every PR in core'sTileView.unreadPrKeys(what makes the tile unread: a thread unread on GitHub, a pulled-in layer's loud news, an unseen Look closer event) getsUnreadDot("Unread") before its number, on the tile (single-PR tiles too) and inDetailContext's list. A topic with unread PRs gets it too, and its bubble countsTopicListItem.unreadTiles(counts are tiles, dots are per PR; the footer's number too). The renderer only reads the field.DetailContextshows kind, title, "PR x of n" and the arrows only for several PRs; one PR is just "PR". - Source chips repeat once per block (
blockRefsinlib/memory.ts): pass its result asMemoryLinerefsin lists. - Whose turn:
TurnLinein the tile footer; the footer turns warm for "Your move". - Coral (
unread) means "new since you looked" and nothing else on a tile, with one exception: the unread dot on PR rows and topics (DESIGN.md "Actions act on what you look at": one dot, no second read-only one). Primary buttons are ink, except Approve:Buttonvariantsafe(--safegreen,--elev-safe), the color of the "Approved" state it produces (2026-09-30). Accent blue is for selection and focus only.
Setup flow
App.tsx shows SetupFlow over the middle and right columns (col-span-2,
no detail pane or tile divider) and SetupSidebar instead of the topics
when useSetupStatus().needed is true or after "Run setup again"
(InstructionsPane's onRunSetup). Once open it stays open (a latch in
App) until Accept's sync ends or the user closes it: Accept makes the
server stop saying needed, and the screen must not vanish mid-sync. The
start sync waits for the setup status and is skipped while setup is
needed; a first-run "Skip for now" runs it. Screens: SetupChecksStep,
SetupSweepStep, SetupReviewStep (+ SetupSectionCard with "Why?",
SetupRepoChoices), SetupDayStep ("Your day": InterruptionsChoice, three aria-pressed
cards for never / batches / asap with InterruptionsArt, small animated
illustrations on the interrupt-* keyframes in app.css, only under
motion-safe:; words in lib/interruptions.ts; the pick lives in
SetupFlow, preselected from useInterruptions, Never while it loads),
SetupAcceptStep, with SetupSteps (worded step
chips) and SetupChip (OK / Fix this / Working ...). The step lives in
App so the sidebar can name it; the draft, edits and picks live in
SetupFlow. Pure helpers in lib/setup.ts, including draftText, a copy
of core's formatInstructionsSections: keep them in step. The repo picks
are a SetupPicks (picksFromDraft: suggested quiet repos on, main repo
"All repos"; toggleQuiet records touched toggles; picksAfterRefine
keeps them and the main repo on a refine).
Selection
Sidebar faces (FaceStack in TopicSidebar): TopicListItem.people is
PR authors only (core topicFaces); teamPill (lib/faces.ts) splits
them into the team pill (you and teammates: sea tint, inset-ring-sea-ring,
PeopleIcon first, avatars overlapping, tooltip "You and your team: …")
and the other authors after it, overlapping the same way.
The sidebar lists topics in sections (lib/queues.ts, sidebarBuckets,
order typed with core's TopicSectionOrder; DESIGN.md "Ownership
sections"): Needs reply, Changes you requested, To review, Team mentioned,
You drive, Your team owns, Other work, then Other topics. Each topic sits
once, in the section core gives it (TopicListItem.section,
topicSection), in core's order; the topic header's breadcrumb reads the
same field on TopicDetail and the same label and dot
(lib/sections.ts). lib/sidebar.ts holds the folds: Other work's area
folds (areaFolds, "More" for single-topic areas), their default
(startsOpen: your PR, move or unread), the rows a folded fold keeps
(rowsWhileFolded, urgent unread) and its header summary; the selected topic counts like an urgent row (a fold holding it stays open, a folded one keeps its row); Other topics
splits into unplaced rows ("not sorted yet" without a dossier) and the FYI
fold. The queue filters still match a topic by any PR. Fold choices are
local UI state for the session; FYI and the Archive start folded.
Dealt-with topics (core quiet) leave You drive, Your team owns and Other
work (DESIGN.md "Dealt-with topics leave the list"): sidebarBuckets(items,
hideDealt) puts them in a dealt:<section> bucket after their section
(dealtItems), so useHeldPlace keeps a selected row that turns quiet or
gets news; DealtLine ("+ N dealt with", dealtLineLabel, fold key
dealtKey) opens them, folded it keeps only the selected one
(rowsWhileFolded), allDealtNote marks an all-dealt header, and Other
work's default is otherWorkStartsOpen. hideDealt is off while the
search or a queue filter narrows. The Archive drawer (retired topics, useFinishedTopics) hides while search
or a queue filter narrows; a finished topic is not in useTopics, so
App opens it by id (pickedFinishedId) instead of through visibleTopic.
The open topic can also leave useTopics while the user is in it (its PRs
went cold and its last unread PR was read): App keeps its last row
(heldTopic, nextHeldTopic / withHeldTopic in lib/selection.ts) and
uses that list as items everywhere, until the user navigates, the repo
scope changes (the server's scope, not the cold list, hides the topic then)
or heldTopicGone says it was merged, archived or deleted (a 404; offline
or a server error keeps the row, ApiError.status) (2026-10-26). Read
items, never topics.data, for anything that decides what is open. The "Topics with
any PR | my PRs | team PRs" switch (QueueFilters) is plain UI state in
App.tsx, not a history entry; it narrows together with the search, and
App passes how many topics it hides for the "N topics … hidden · Show
all" line. Relation
corrections go through correctMemory with relation set
(RelationLine), local only. TileGrid shows three groups by
TileView.group, always Unread, Open, Dealt with (gridGroups in
lib/queues.ts, order typed with core's TileGroupOrder), tier order
inside, snoozed last; empty groups don't render. Dealt with is folded
until opened, then as the user last clicked it (dealtWithOpen in App,
kept for the session; search or a selection never changes it), and opens
while the search filters or one of its tiles is selected. No All / Unread
toggle (removed 2026-09-30). Queue filters pick topics; tiles inside a topic are never faded by them.
A click anywhere on a tile selects it (onTileClick in Tile.tsx), except
on a control inside it (button, link, menu: clickedControl), which keeps
its own action; a PR row selects that PR. The keyboard path is the title,
a <button>. A click on the already selected tile keeps the open PR.
Tiles stay in one column (DESIGN.md "Three-pane balance"). The selected
tile keeps the place it had when it was selected, in its group (useHeldPlace over
holdPlace, lib/hold-place.ts), and the open topic's sidebar row too,
until the selection moves; its look still changes right away. In the
sidebar, a selection made with the pointer over it keeps the rows it left
until the pointer leaves (useSidebarHold, pure part
lib/sidebar-hold.ts); fold helpers in lib/sidebar.ts take those kept
ids, not only the open topic's.
The topic's ✨ buttons under a search filter read
shownTopicOffers(detail.agent, shownTiles) (lib/agent-actions.ts),
never detail.agent directly: they count and act on what is on screen.
The agent's proposals list is "Suggestions" in every UI word (sidebar
row, pane title, cards; 2026-10-11, was "Inbox"). The code keeps its
inbox names (InboxPane, pane 'inbox'). In copy, "inbox" only ever
means the user's GitHub inbox.
App.tsx holds the picked topic, which middle pane shows (topic, Suggestions,
"Your instructions", the notifications debug list and "Handled quietly",
which both also take the detail pane's column), and the picked
tile + PR. Everything else is
derived on render (resolveSelection in lib/selection.ts): a missing
pick falls back to the first topic, its first tile and that tile's lead PR;
a vanished tile id follows its PR to the tile that holds it now. Don't
mirror server data into useState. The one exception is KeptView: what
was on screen for the current entry and filter key (queue filter plus the
query the shown search results answer). While both stay the same it is
shown again even when it no longer matches, so a refetch after an action
never moves the selection; a new pick or a filter change drops it.
The picks live in a back / forward history (lib/history.ts, hook in
lib/use-nav-history.ts): every user pick goes through go() in App.tsx,
which pushes an entry unless it shows what is already on screen. Cmd+[ / ],
the mouse side buttons and the trackpad swipe (main process 'swipe' -> preload
onSwipe) move through it. A click on a Mac notification arrives as preload
onOpenPing and goes through go() too. Anything new that navigates should
call go().
The repo scope (RepoScopeMenu, a radio list: All repos or one) is
applied by the server: /api/topics and /api/search only return the
topics with a PR in the chosen repo, and /api/topics/:id always returns
every tile with repoLabel set on tiles and PR rows from another repo
(drawn with RepoLabel from pills.tsx). The renderer never filters or
fades by repo itself. Setting the scope or a quiet
repo goes through useActions() (setRepoScope, setRepoQuiet, local,
not on the GithubWrite list). Popovers use lib/use-dismiss.ts to close
on outside click and Escape.
The title bar search filters, it has no result list: GET /api/search
(matcher searchTopics in core) returns matching topics and tiles, the
sidebar and tile grid hide the rest (lib/search.ts). When the filter hides
the picked topic, the first match shows instead; that is derived, not a
history entry, and clearing the filter brings the pick back. Only a filter
change does that: a topic that drops out of the results after a refetch
stays on screen and listed (KeptView).
Electron shell
- The app is PostPile (renamed from code-manager 2026-09-28). The icon lives
in
apps/desktop/build/(icon.icns,icon.png, original artwork inicon-source.png); main usesbuild/icon.pngfor the window and the dev Dock icon. The title bar logo isrenderer/src/assets/logo-64.png, a crop of the same icon. Regenerate it withsipswhen the icon changes. - Names on the wire: preload global
window.postpile, IPC channelspostpile:*, API headerx-postpile-token. titleBarStyle: 'hiddenInset': the renderer draws the 52px title bar and keeps 88px free on the left for the traffic lights. Left column: logo + name, DEV badge / Sample data pill, then the back / forward chevrons at the right end (ml-auto), next to the centered search. Interactive elements in the bar must stay clickable (.drag-regionsets them to no-drag).- The preload hands over only the API URL and token (asked from main with
ipcRenderer.sendSync('postpile:connection'); main answers only its own page, and nothing secret goes intoadditionalArguments), plus the swipe and notification-click listeners andsendTestNotification("Send a test notification" in the sidebar's Interruptions menu, throughuseActions().sendTestNotification). The welcome notification (main/welcome.ts, flag in userData) comes only once the user picks batches or asap, never on a plain first launch. The Dock badge is main's (BoardWatcherover the engine'spingBadge); the renderer only sendstileVisited. As a plain web page the renderer takes?api=…&token=…instead. - Packaging:
pnpm dist(root) ->apps/desktop/dist/mac-arm64/PostPile.app, config inelectron-builder.yml. Main must stay self-contained: keep runtime packages bundled by electron-vite (the desktop package has only devDependencies, and no node_modules go into the app). Don't read files frombuild/at runtime in a packaged app; it is not shipped. - Dev vs packaged: unpackaged (
pnpm desktop) setsPOSTPILE_PROFILE=devand userData to the dev data folder before anything else, so it never opens the real database; the title bar shows a DEV badge fromAppConfig.profile. Main takes the database lock (engineFromEnv({ lockKind })) and shows a Quit dialog when another process holds it;requestSingleInstanceLockfocuses the first window on a second launch. - Logs: main and the in-process server log to
~/Library/Logs/PostPile/main.log(PostPile-devfor dev runs,POSTPILE_LOG_DIRoverrides), console output plus uncaught errors, rotated at 5 MB, 3 files (main/file-log.ts). Help › Reveal Logs shows it in Finder. The sync logs its start, a summary and each error. - Crash and wake signals (DESIGN.md "Memory on big boards"):
main/run-marker.ts(RunMarker) writesrunning.jsonin userData at start andshutdownOnceremoves it; one left over sendsapp_crashed_last_run. Every deliberate quit must go throughshutdownOnce(or clear the marker, like the install fallback), else it counts as a crash.crashReporterstarts at the top ofindex.tswithuploadToServer: false, dumps under Electron's defaultcrashDumps(Crashpad/in userData; neversetPathit, the folder does not exist on a fresh install).powerMonitorsuspendcallsengine.noteSuspend()(the auto sync's timer stops, a storage job pauses),resumecallsengine.noteWake()(it waits a few minutes, a storage job 30 s); both are logged. - The last sync report (errors, timing) is stored in meta
last_sync_reportand shows in the footer's "last full sync" and the title bar's sync status tooltips (lib/sync-report.ts) and in the notifications debug pane. Title bar elements with atitleare no-drag, so tooltips work. - The title bar's sync status headline follows the live poll
(
useLiveStatus,lib/live.tspollIsFresh): "up to date" while it keeps up, an age only once it fell behind or is off. Don't put the full sync's age back in the headline: it reads as stale data while the poll runs. - Sync runs once on app start, on "Sync now", and every
AppConfig.autoSyncMinutes(default 60) in the background (the engine'sAutoSyncSchedule, started by main).POSTPILE_SYNC_ON_START=0(AppConfig.syncOnStart) skips the start sync.useActions().syncingis true for any running sync (LivePollStatus.syncRunning), so the title bar shows background syncs too;lastSyncis the newer of this window's and the stored report (newerReport). The main process runs the live poll (engine.startLivePoll) and shows Mac notifications (main/mac-notifier.ts); closing the window hides it on macOS, Cmd+Q quits.
Layout and copy rules (2026-10-10)
- Never use
animation-fill-mode: both(orforwards) on an opacity or translate animation that stays on a settled element: the finished animation keeps a stacking context, which trapsz-20menus inside the element so the next tile paints over them. The-insettle steps inapp.cssusebackwards;lib/app-css.test.tspins it. - Footer popovers (
absolute bottom-full) are children of awhitespace-nowrapstrip: give themwhitespace-normal. - Time in running text goes through
whenLabel(lib/time.ts: "just now", "5m ago", "yesterday"), neverageLabel(...)} ago, which says "now ago". Counts with a word go throughplural(lib/plural.ts). - Long unbroken tokens (cache keys, branch names) in titles use
[overflow-wrap:anywhere]. lib/csp-safe-zod.tsturns zod's jitless mode on before any schema is built, so zod'snew Function("")probe does not log a CSP violation on every load.main.tsximports it first; keep the CSP free ofunsafe-eval.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

