Skip to content

feat(mobile): phase-grouped sidebar, member tagging and provider usage - #149

Merged
tusharbhardwaj-bk merged 6 commits into
expbkmainfrom
t3code/congee
Aug 31, 2026
Merged

feat(mobile): phase-grouped sidebar, member tagging and provider usage#149
tusharbhardwaj-bk merged 6 commits into
expbkmainfrom
t3code/congee

Conversation

@tusharbhardwaj-bk

@tusharbhardwaj-bk tusharbhardwaj-bk commented Aug 31, 2026

Copy link
Copy Markdown

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 a768835c4 had already extracted 1,780 lines of sidebar logic into client-runtime specifically so mobile could share it — and then nothing consumed it. Mobile referenced phaseSidebar in zero files.

Approach: extract, then render

apps/web/src/components/PhaseGroupedSidebar.tsx is fork-owned (absent from upstream/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:

File Seam
apps/mobile/src/persistence/mobile-preferences.ts two optional preference keys
apps/mobile/src/features/settings/SettingsRouteScreen.tsx one Experiments section
packages/client-runtime/src/state/session.ts operatorUserIdFromSessionState

scripts/check-fork-markers.ts passes.

What's here so far

  • Row assembly extracted — web's ~95-line useMemo becomes buildPhaseSidebarRows in client-runtime. Web's 230 sidebar tests pass untouched, which is the proof it's behaviour-preserving.
  • Mobile phase sidebar behind Settings → Experiments → Phase-grouped Sidebar, off by default. Lifecycle groups, the full metadata lane in web's order, tree nesting with expanders, unread dots, relative times.
  • Tone tables duplicated deliberately — Tailwind scans apps/web, uniwind scans apps/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/, but apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs deletes the aps-environment entitlement 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 existedapps/mobile/src/features/usage/ ships a Usage screen covering Codex and Claude. Only the at-a-glance header bars were missing.

Notes for review

  • 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.
  • The PR badge is omitted on mobile because mobile doesn't aggregate per-thread VCS status — rendering a permanently empty badge would be a lie.
  • Ownership facets resolve against the selected thread's environment; mobile has no single primary environment. Same rough edge web's identity module documents.

Verification

vp run typecheck clean in mobile, web, client-runtime and shared (note: npx tsc does not catch the Effect diagnostics CI enforces). Lint and vp fmt --check clean. Fork markers pass. No device verification yet.

Model: Claude Opus 5 (1M context), harness: Claude Code in T3 Code.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

tusharbhardwaj-bk and others added 3 commits August 31, 2026 18:54
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>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.6 KiB 15.1 KiB
Codex Thread snapshot wire 7.2 KiB 7.3 KiB
Codex Live turn WebSocket wire 6.4 KiB 7.8 KiB
Codex Live turn WebSocket decoded 55.7 KiB 66.4 KiB
Codex Live turn messages 11 21
Claude Total thread wire 13.6 KiB 15.1 KiB
Claude Thread snapshot wire 7.2 KiB 7.3 KiB
Claude Live turn WebSocket wire 6.4 KiB 7.8 KiB
Claude Live turn WebSocket decoded 56.5 KiB 66.4 KiB
Claude Live turn messages 11 21

Baseline: unavailable · PR result: d8ba486 · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 110.4 KiB
  • Claude decoded thread snapshot: 111.1 KiB

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>
@github-actions github-actions Bot added size:XXL and removed size:XL labels Aug 31, 2026
tusharbhardwaj-bk and others added 2 commits August 31, 2026 19:42
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>
@tusharbhardwaj-bk
tusharbhardwaj-bk merged commit 2a22b35 into expbkmain Aug 31, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant