feat(mobile): phase-grouped sidebar, member tagging and provider usage - #149
Merged
Conversation
a768835 moved the phase-sidebar logic into client-runtime so mobile could use it, but left row assembly behind: a ~95-line useMemo inside the 3,356-line PhaseGroupedSidebar.tsx. Mobile therefore still could not build a row, which is why it references phaseSidebar in zero files. Extracts it as `buildPhaseSidebarRows`, taking threads, projects, server configs, VCS status and visit timestamps as plain data. The web sidebar now calls it and keeps only its own anti-flap ref. `hasUnseenCompletion` moves across too, since the builder needs it; Sidebar.logic.ts re-exports it so every existing web import is unchanged. It is deliberately not merged with the neighbouring `isThreadUnread` — that one treats an unparseable visit timestamp as read, and the difference drives the row's unread dot. PhaseGroupedSidebar.tsx is fork-owned (absent from upstream/main), so this refactor costs nothing at merge time and removes fork lines from a file we would otherwise keep reconciling. Noted while testing: `resolvePhaseSidebarDisplayPhase` ignores its previous phase argument, so the anti-flap fallback it implies does not currently happen. Left as is rather than changing web behaviour; the builder threads the map through so restoring it would serve both clients. Verified: web's 230 sidebar tests pass untouched, 25 in client-runtime including 10 new fork-owned ones for the builder, and `vp run typecheck` is clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First half of the mobile experimental sidebar: everything that decides what a row says, and the list that renders it. Not wired into the app yet, so this changes no behaviour. - `phaseSidebarPreferences.ts` holds the pure opt-in resolver and visit-map pruning. Split from the hooks on purpose: anything importing the preferences atom transitively imports react-native, which the unit test bundler cannot parse (Flow). This mirrors the existing threadListV2.ts / hook split, which exists for the same reason. - `usePhaseSidebarRows` binds mobile's thread shells, projects and server configs to the shared `buildPhaseSidebarRows`, so mobile computes nothing itself and cannot drift from web. - `phaseSidebarRowTone.ts` carries mobile's literal class strings. These are duplicated from web deliberately: Tailwind scans apps/web and uniwind scans apps/mobile, so a class string produced in client-runtime would not be generated for either build. Only the literals are duplicated; every decision is shared. - `PhaseSidebarRowView` renders the metadata lane in web's order — repository, worktree codename, Linear tag, Mattermost mark, priority, owner, provider, relative time, unread dot, subtree expander. - `PhaseSidebarList` flattens the tree into one array before rendering, because a nested render tree of hundreds of rows drops frames on scroll. `operatorUserIdFromSessionState` moves into client-runtime so both clients answer "who am I" identically; BK mobile carries no Clerk key, so identity always comes from the environment session. Web's fork module re-exports it. Two things deliberately absent: the PR badge, because mobile does not aggregate per-thread VCS status and rendering a permanently empty badge would be a lie; and any drag affordance, which is the next commit. Verified: 7 fork-owned tests, `vp run typecheck` clean in mobile, web and client-runtime, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the experimental sidebar reachable: Settings → Experiments → Phase-grouped Sidebar. Off by default, so nothing changes for anyone who does not turn it on. The sidebar replaces the whole thread list rather than reshaping it, so the stock list keeps its exact behaviour when the flag is off — and turning the flag off is a clean way to isolate a regression after an upstream merge. Selecting a row records the visit, which is what drives the unread dot. Ownership facets resolve against the environment of the selected thread: mobile has no single primary environment, and this mirrors the rough edge web's identity module already documents. Added to both Settings render sites (phone and tablet); a behaviour reachable one way is usually reachable more than one. Long-press currently selects the row. That is deliberate interim behaviour — the context menu lands next, and a long-press that silently does nothing reads as broken. Verified: 54 tests across the phase sidebar and the stock list's own suite, `vp run typecheck` clean, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Three things, all reachable from the phase sidebar. **Row actions.** Long-press a row for People, settle/reopen, snooze/wake, pin/unpin, priority, force stop and archive. The action set is built by a pure function gated on the row model's capability flags, so an older server offers fewer items rather than failing an RPC — and every state that can be entered can be left, per the fork's rule that a one-way door is a bug. Destructive actions sort last. Dispatch reuses the existing `useThreadListActions`, which already handles failure reporting, rather than duplicating its commands. **Member tagging.** `ThreadMembersSheet` adds and removes members and transfers ownership through the `threadCommands` that already existed in client-runtime. Ordering puts the owner first, then existing members, then everyone else alphabetically — scrolling to find who is already tagged is the mistake that ordering avoids. Worth recording a wrong turn: I first wired this to `users.list`, whose `EnvironmentUserId` is a *different id space* from the `UserId` thread membership uses, and the brands caught it. Thread tagging needs the org directory at `/api/orchestration/users`, which had no shared client. Added `fetchOrchestrationUsers` plus a per-environment atom to client-runtime's session module, where the prepared-connection and auth-header plumbing already lives, so web can adopt it later instead of keeping its own HTTP path. **Provider quota bars.** Claude and Codex remaining-percent in the sidebar header, gated on the `providerRateLimits` capability. All the arithmetic was already shared, so this is layout. It deliberately does not duplicate the existing Settings → Usage screen, which already covers both providers with per-day charts — this answers "how much is left" at a glance. Verified: 28 fork-owned tests, `vp run typecheck` clean in mobile and client-runtime, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Full-fidelity drag, as asked. Dropping ON a row re-parents it; dropping near a row's top or bottom third reorders a pin. Refused drops tint red with the reason rather than snapping back silently. The rules are a pure module with 23 tests, because a bad drop re-parents real work: it refuses a drop on itself, across environments, onto its own descendant, onto the parent it already has, and anything that would exceed PHASE_SIDEBAR_TREE_MAX_DEPTH — counting the dragged subtree's own depth, not just the row. It also survives a cycle in server data rather than hanging the gesture. **Design decision worth flagging.** I first put the lift on long-press, which was wrong twice over: long-press already opens the row's context menu, and a list-level gesture cannot know which row the finger started on, so `beginDrag` was never called at all. Dragging now starts from an explicit grab handle on the row, which removes both problems — no gesture competes with scroll, and the subject is unambiguous. Re-parenting reuses the same `thread.meta.update` web's setThreadParent calls. Reordering delegates to the existing `movePinnedThread`, which already owns the fractional order-key planning and the `threadPinReorder` capability check. Direction comes from `pinOrderKey` — the sortable key the server assigns — not from `pinnedAt`, which is when the pin happened and says nothing about position. My first attempt compared `pinnedAt` and was simply wrong. **Known limitation:** because `movePinnedThread` moves one position per call, one drag moves a pin one place. Dragging a pin a long way needs repeating. Expressing an arbitrary target would mean duplicating planPinnedMove's fractional-index logic here, which is not worth the divergence. Verified: 51 fork-owned tests across the sidebar and members, `vp run typecheck` clean, lint and fmt clean. No device verification — the gesture feel in particular is unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Filter pill from the desktop sidebar, with the facets that exist there: lifecycle, repository, provider, "started by me", priority-first and sort direction. Which facets exist, what they match and how they sanitize are all decided in client-runtime, so this is a sheet of toggles over shared logic. Providers list only the drivers actually present in the current rows, so the sheet does not offer Cursor or OpenCode to someone who never runs them. Filters live in the sidebar's own state and are passed to both the sheet and the list, so the two cannot disagree about what is active. The pill tints when any filter is on, and "Clear all" only appears when there is something to clear. Verified: 51 fork-owned tests, `vp run typecheck` clean, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The fork's experimental sidebar is how we actually drive T3 — lifecycle grouping, Linear tags, worktree codenames, priorities, owners, provider glyphs, session nesting. None of it existed on the phone; mobile rendered upstream's flat Sidebar V2.
Commit
a768835c4had already extracted 1,780 lines of sidebar logic intoclient-runtimespecifically so mobile could share it — and then nothing consumed it. Mobile referencedphaseSidebarin zero files.Approach: extract, then render
apps/web/src/components/PhaseGroupedSidebar.tsxis fork-owned (absent fromupstream/main), so refactoring it is free at merge time. That unlocked the cheap path — move the remaining shared logic down, then write mobile views against it. Mobile computes nothing itself, so the two clients cannot drift.Only three upstream-owned files are touched, each a single marked seam:
apps/mobile/src/persistence/mobile-preferences.tsapps/mobile/src/features/settings/SettingsRouteScreen.tsxpackages/client-runtime/src/state/session.tsoperatorUserIdFromSessionStatescripts/check-fork-markers.tspasses.What's here so far
useMemobecomesbuildPhaseSidebarRowsin client-runtime. Web's 230 sidebar tests pass untouched, which is the proof it's behaviour-preserving.apps/web, uniwind scansapps/mobile, so a class string produced in client-runtime is generated for neither. Only the literals are duplicated; every decision is shared.Still landing on this branch
Drag-to-reorder and move-under (full fidelity, as requested), the row context menu, the filter sheet, thread member tagging, and the provider rate-limit bars. Kept as separate commits so it stays reviewable by commit.
Deliberately not here
Native notifications and the Apple Watch app. Not a code gap: mobile already has the full push stack in
features/agent-awareness/, butapps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjsdeletes theaps-environmententitlement because a free Apple ID cannot sign push. Widgets and Live Activities aren't built on that path either, and there is no watchOS target in the repo. Needs a paid Apple Developer membership plus a from-scratch watch target; filed separately.Provider usage already existed —
apps/mobile/src/features/usage/ships a Usage screen covering Codex and Claude. Only the at-a-glance header bars were missing.Notes for review
resolvePhaseSidebarDisplayPhaseignores its previous-phase argument, so the anti-flap fallback it implies does not currently happen. Left as-is rather than changing web behaviour; the builder threads the map through so restoring it would serve both clients.Verification
vp run typecheckclean in mobile, web, client-runtime and shared (note:npx tscdoes not catch the Effect diagnostics CI enforces). Lint andvp fmt --checkclean. Fork markers pass. No device verification yet.Model: Claude Opus 5 (1M context), harness: Claude Code in T3 Code.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.