Skip to content

chore: promote expbkmain to bkmain (host-down resilience, cross-machine lineage, mobile sidebar) - #150

Merged
tusharbhardwaj-bk merged 45 commits into
bkmainfrom
expbkmain
Sep 2, 2026
Merged

chore: promote expbkmain to bkmain (host-down resilience, cross-machine lineage, mobile sidebar)#150
tusharbhardwaj-bk merged 45 commits into
bkmainfrom
expbkmain

Conversation

@tusharbhardwaj-bk

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

Copy link
Copy Markdown

Promotes expbkmain (38a334283) to bkmain. Verified live on expbkt3 before merging: deployed-sha matches, t3-expbkt3.service active, HTTP 200, served bundle checked for the startup regression fixed in #159.

Merges cleanly into bkmain — no conflicts. Also re-runs the bkmain deploy workflow, whose last run (for #155) failed on a flaky 100 ms perf assertion in session-logic.test.ts, so bkt3 is still serving c1331e7af and #155's desktop-bundle change deploys with this.

⚠️ Merging this restarts bkt3 and kills every live session

t3-bkmain.service restarts on deploy, which ends every in-flight agent turn on bkt3.

What's included (25 commits, 4 promoted PRs)

Host-down resilience (#156, #157, #159) — the desktop/web client keeps working when a remote host is unreachable:

  • a host's sessions stay listed across an app restart instead of vanishing (last-good platform registration is persisted; a transient outage no longer wipes the cache)
  • "New child thread with this context…" works from the local cache while the host is down, with a provenance note; "Save full context as file" exports it
  • parent/child sessions may live on different machines (parentEnvironmentId, migration 1024 — additive, verified against the live bkt3 database shape)
  • cached thread history deepens in the background inside a 200 MB / 10 MB-per-thread budget with LRU eviction; offline threads read as "Cached copy — host offline" and are searchable
  • fix(web): stop desktop startup from crashing #159 fixes a module-scope EnvironmentId.make("") that froze desktop startup at the splash

Mobile SideStore update feed (#158) — publishes the sideload update source.

Earlier, already in this PR — mobile phase-grouped sidebar (off by default), mobile plan review, member tagging on mobile, provider quota bars, branded mobile build.

Known gap, follow-up

The client cache is not written while a turn is running (upstream pingdotgg#4006). A host that dies mid-turn therefore loses the partial response from the local cache until the host is back; the server keeps every delta. Fix planned: persist once on disconnect.

Risk

Every new surface is behind a flag or capability gate; offline history sync can be disabled with VITE_T3_OFFLINE_HISTORY_SYNC=false. Fork markers pass; baseline shrank.

Model: Claude Fable 5.1 (#156/#157/#150) and GPT-5.6-sol (#159), harness: Claude Code / Codex in T3 Code.

🤖 Generated with Claude Code

tusharbhardwaj-bk and others added 16 commits August 31, 2026 06:50
The fork had no mobile artifact. app.config.ts and eas.json are branded end to
end for upstream's Expo project — owner, EAS project id, Apple team,
com.t3tools.t3code bundle ids and an OTA update URL — so there was nothing to
install that could pair with a bkt3 server, even though the protocol side of
apps/mobile has been forked for a while.

Adds a fork build identity in a new app.config.bk.ts (BK T3 Code,
work.beknown.bkt3code.mobile, t3code-bk://, expo-updates disabled) applied
through a two-line seam in app.config.ts, an Android release-signing config
plugin, a build script for both platforms, and a push-triggered workflow that
publishes the APK and an unsigned IPA as GitHub release assets.

Android sideloads the signed APK; iOS ships unsigned for SideStore to re-sign
with a free Apple ID, which is why the build rides upstream's existing
T3CODE_IOS_PERSONAL_TEAM path — that already strips the entitlements a free
Apple ID cannot sign. Builds report ${version}+bk.${sha7} as client_version so a
binary older than the server it paired with is identifiable: T3 has no protocol
handshake, and a stale client silently stops receiving orchestration updates.

Pairing needed no changes; the web QR is a plain https pairing URL the release
build's scanner already accepts.

Claude Opus 5 in T3 Code (Claude Code harness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mobile

The fork's experimental control-center sidebar keeps all of its pure logic under
apps/web, which the React Native app cannot import. Porting the phase-grouped
session list to mobile therefore had to either duplicate ~1900 lines or move
them, and duplication of the cycle guard and the settle rules would drift.

Moves the logic into three fork-owned client-runtime subpaths — state/phaseSidebar,
state/phaseSidebarTree, state/provider-rate-limits — and folds in the sibling
modules the mobile port also needs: the lifecycle counters, the running-session
emphasis decision, the move-under-session candidates, and the unread predicate.
Every apps/web file left behind is a re-export shim, so PhaseGroupedSidebar.tsx
and its 150-odd existing tests are untouched and still pass.

What stayed in apps/web: the helpers that emit Tailwind class names. Tailwind
finds literal class strings by scanning source under apps/web, so moving those
would have silently dropped the styles from the build.

Two things the move had to fix. Every .toSorted() call is now sort-a-copy —
Hermes ships no ES2023 change-array-by-copy methods, so those would have worked
in every browser test and crashed on a phone; a new test asserts this by
deleting the methods from Array.prototype. And resolveSettledTimestamp is copied
rather than imported, because its home is an upstream-owned file and
re-exporting from there would put a fork edit inside it for no gain.

No behaviour change.

Claude Opus 5 in T3 Code (Claude Code harness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mobile had no way to act on a proposed plan at all: the thread showed a
"Plan Ready" pill and the only way forward was a desktop. This adds a
native plan-review surface — read the plan, question specific lines,
then approve or request changes.

Web reviews plans in a Plate rich-text editor, which does not port to a
phone. The useful phone operations are read, question, and decide, so
this surface is read-plus-annotate and reviewer edits to the plan body
stay a desktop affordance; `editedMarkdown` is always null on submit,
which the contract already allows.

Line selection deliberately copies the diff reviewer's gesture (tap to
anchor, tap to extend, tap again to clear) so reviewers moving between
the two surfaces do not learn two interactions. Anchoring is by quoted
text, re-located against whichever version is on screen, so a quote the
agent has since rewritten is still listed rather than silently dropped.

Fork-cost notes: the two upstream files touched take one marked line
each (an import plus one element in ThreadRouteScreen, a route pair in
Stack.tsx), and the banner resolves its own visibility so no
conditional leaks into the thread screen. planReviewMarkdown moved from
apps/web into client-runtime rather than being copied, since it was
already DOM-free and both clients need it.

Verified: 31 new tests across the three pure modules, mobile tsc and
lint clean, web and client-runtime tsc clean after the move.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…marked

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading the whole server-config map re-rendered the banner whenever any
unrelated environment's config changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves one conflict in apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts.

This is the relocation hazard AGENTS.md warns about, not an ordinary
content conflict. a768835 moved the phase-sidebar logic out of this
file into @t3tools/client-runtime/state/phase-sidebar (leaving only the
Tailwind class-name helpers plus an `export *` re-export), while
317f7ce on expbkmain added to the same block in place. Taking either
side alone loses work: ours drops the Mattermost feature, theirs
re-duplicates logic that now lives in client-runtime.

Resolution: keep the relocation, and port expbkmain's two additions into
the module the logic moved to —

  - `PhaseSidebarMattermostLink` + `resolvePhaseSidebarMattermostLink`
  - `mattermostLinkSupported` on `PhaseSidebarRow`

Both were verified to be the *only* things expbkmain added to the block,
by diffing its export set against the relocated copy and reading every
hunk since the merge base. apps/web needs no change: the existing
`export *` re-export means PhaseGroupedSidebar.tsx imports both symbols
from exactly the same path as before.

Verified: MattermostThreadBadge.test.tsx (6 tests) imports the resolver
through the web re-export path and passes, phaseSidebar and
planReviewMarkdown suites pass, and web + client-runtime tsc are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught two failures that the local scoped runs could not: the mobile
suite as a whole, and repo-wide formatting.

`src/lib/connection.test.ts` failed to load at all with "__DEV__ is not
defined". bkBuildManifest.ts imported expo-constants at module scope,
and authClientMetadata imports that module, so every test reaching
authClientMetadata pulled in expo-modules-core — which reads React
Native's `__DEV__` global as an import side effect. vitest does not
define it.

That module's own doc comment already stated the intent ("so the
version-formatting logic stays testable without pulling react-native
into the unit test environment"); the split just did not go far enough
to cover its own consumers. The manifest read is now function-scoped
and fail-soft: outside a real Expo runtime there is no manifest, and
"no manifest" already means "no SHA", which is what connection.test.ts
asserts (plain "1.0.4", no +bk suffix).

Also formats four files that were carrying pre-existing whitespace
drift from commits made outside this worktree, which the staged-files
pre-commit hook never saw. `vp fmt --check` is now clean across all
3350 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gnostic

Moving this module out of apps/web and into client-runtime subjected it
to the package's Effect diagnostics, which reject `Date.now()` in favour
of Effect's Clock. `nextPlanDiscussionId` is a client-side id generator
called straight from a React event handler; threading a Clock through it
would add real plumbing for no benefit, and the wall clock is only there
to stop ids colliding across a reload (a bare counter restarts at 1 and
would make `upsertDiscussion` edit an existing discussion instead of
creating one).

So it takes the same file-scoped opt-out with a stated reason that
threadSettled.ts already uses for UI-level time.

Worth recording why CI caught this and my local runs did not: `vp run
typecheck` uses tsgo with these diagnostics, while a bare `npx tsc
--noEmit` does not. Re-verified with `vp run typecheck` in all four
touched packages (client-runtime, shared, mobile, web) — clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(mobile): review proposed plans from the phone
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>
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>
feat(mobile): phase-grouped sidebar, member tagging and provider usage
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. 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: 38a3342 · 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.5 KiB
  • Claude decoded thread snapshot: 111.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

tusharbhardwaj-bk and others added 3 commits September 1, 2026 11:58
Sending a message with an image on bkt3 could show a red banner reading
"Attachment 'image.png' cannot be sent: attachment not found (removed or
expired)" on a turn that had, in fact, started and was already running.

Since upstream pingdotgg#8048 the web client uploads an image before sending and
references it by a `pending-…` id, then releases that upload as soon as
the server acknowledges the turn. The fork's durable outbox can dispatch
the same queued turn twice: its replay loop is gated on a send state that
clears on the server's *projection* acknowledgement, which lands before
the RPC reply removes the queue row. The second dispatch is normalized
before any commandId dedup, finds the pending upload already released,
and fails — marking a live turn as failed.

The outbox's own comment ("a failed local deletion leaves a harmless
duplicate that the server will deduplicate by commandId") was true before
uploads existed. It is not any more, so the durable send path now records
which turns it has on the wire and which it has delivered. The replay loop
skips the first and discards the second: a row left in place would latch
the composer as permanently busy.

Mobile is unaffected — it still queues images as inline data urls.

Written by Claude Opus 5 in T3 Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(web): stop a sent image message reporting "attachment not found"
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 12:35 — with GitHub Actions Active
tusharbhardwaj-bk and others added 2 commits September 1, 2026 14:01
…e-capable local environment

Managed BK desktop builds were client-only: no local backend, unusable
without the central server. They now stage and start the bundled T3
backend as a secondary local environment (pool id bk-local, the same
rails as a WSL secondary) while the central server stays the primary,
so team features like thread member tagging keep working there and
local threads run on the user's machine.

The bundled backend is keyless and single-user: no Clerk reaches it, so
it advertises no team capability and member surfaces stay hidden on
local threads. When the central server is unreachable, a paired device
now renders the app and keeps the local environment usable instead of
dying on the root error screen (fork/managedOfflineGate); a revoked or
expired pairing still routes to the pairing gate.

The renderer is served from the client assets already inside the staged
server tree, which retires the separate apps/web/dist staging and
returns desktopLocal.ts to upstream byte-for-byte.

Built by Claude Fable 5 on Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(desktop): bundle the backend into managed BK builds as an offline-capable local environment
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 14:26 — with GitHub Actions Active
tusharbhardwaj-bk and others added 3 commits September 1, 2026 14:26
… phone

Two bugs with one root cause: I wired both features into a surface that does
not exist on a phone, then reported them as delivered.

`ThreadNavigationSidebar` only renders when `layout.usesSplitView` is true, and
`deriveLayout` returns `usesSplitView: false` for any compact layout — so on an
iPhone it never renders at all. The phone's thread list is
`features/home/HomeScreen.tsx`, an entirely separate surface. Enabling the
Settings toggle therefore did nothing visible, and member tagging had no
entry point whatsoever, because its only one was that sidebar's row menu.

Fixes:

- Extract the whole sidebar into `PhaseSidebarPane`, mounted by BOTH
  `HomeScreen` (phone) and `ThreadNavigationSidebar` (tablet split view). One
  pane, so the two surfaces cannot drift — which was the real risk in wiring
  them separately.
- Add People to the stock thread row's long-press menu, on all four variants
  (card, slim, snoozed, legacy). Member tagging now works whether or not the
  experimental sidebar is enabled, which is how it should have shipped.

Caught while doing it: those menu arrays are recomposed positionally
(`CARD_MENU_ACTIONS[0]`, `[1]`, `.slice(1)`), so my first attempt at inserting
People shifted the indices and silently dropped the destructive Delete action.
People is now composed explicitly at the head of each menu instead.

This is the "hit every surface" rule in AGENTS.md, and I did not follow it — I
wired one entry point and assumed it was the entry point. Nothing about it
would have shown up in CI; only running the app on a phone did.

Verified: 133 tests across 10 files, `vp run typecheck` clean, lint and fmt
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fork-marker check flagged four unmarked hunks in an upstream-owned file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(mobile): make the phase sidebar and member tagging reachable on a phone
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 14:49 — with GitHub Actions Active
tusharbhardwaj-bk and others added 13 commits September 1, 2026 16:07
The phone reports `1.0.4` where a fork build must report `1.0.4+bk.<sha7>`.
The `+bk.` suffix is the only way to tell which commit a binary came from, and
docs/operations/bk-mobile-build.md leans on it to detect client/server version
skew — so losing it made the fork blind to exactly the failure mode that
document exists to prevent.

I caused it. To stop `expo-constants` dragging expo-modules-core onto the
import graph of tests reaching `authClientMetadata` (it reads `__DEV__` at
import time, which vitest does not define), I made the manifest read a
fail-soft `require`. That swallowed the real read too, so `bkBuildGitSha()`
returned null in a genuine Expo runtime and every build since has reported a
bare version.

The repo already had the right answer: `features/cloud/publicConfig.test.ts`
mocks expo-constants. So this restores the static import — matching every
other consumer in the app — and mocks the module in `connection.test.ts`
instead, which is where the import-graph problem actually was.

Verified: 245 tests across 26 files, `vp run typecheck` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s down

A remote host that was already unreachable when the app started came back
empty: platform registrations are rebuilt from a live descriptor fetch, so a
host that did not answer was never installed, and its cached shell and thread
snapshots were never rendered. Worse, a host that dropped mid-session fell out
of the reported set and `removePlatformEnvironment` cleared its caches, turning
a temporary outage into permanent local data loss.

The last registration a host resolved is now persisted, so a disconnected start
installs the environment immediately and lets the supervisor retry in the
background. Cached data is only discarded when the user removes a connection by
hand. On desktop, a locked OS keyring no longer reports "no saved connections"
for the whole run; the catalog waits a bounded moment for it to open.

Handing a session off now works while its host is unreachable, which is when it
matters most. The digest renderer moved to `@t3tools/shared/sessionDigest` so
the client can render the same digest from its cached copy of the thread; the
fallback is chosen from the connection phase, because an environment call parks
rather than fails while a connection is retrying. The digest says where it came
from and that git state and any un-synced messages are missing.

Model: Claude Opus 5 (1M context), harness: T3 Code.
A cached thread only held the window that was loaded to render it — about ten
user turns — which is thin as the basis of a handoff once its host goes away.
The open thread now walks the existing pagination backwards while its host is
reachable, so the copy on this device grows toward the whole conversation
before anyone needs it. It reuses the request the "load earlier turns" control
already makes, and is driven by page state rather than a timer, so nothing
polls; pages are spaced so a backfill never starves live traffic.

Caching whole conversations is unbounded by nature, so this also adds the part
that says stop. A startup sweep evicts least-recently-opened threads until the
cache fits (roughly 200 MB total, 10 MB for any one thread), and never evicts a
thread opened in the last week, one pinned because its history seeded a
handoff, or one that is the parent of a thread still cached. Evicting costs
history, never correctness: the next open re-fetches from the host.

Bookkeeping lives in its own IndexedDB store rather than on the snapshot, so a
client that does not know about it loses nothing, and the mobile cache is
untouched.

Model: Claude Opus 5 (1M context), harness: T3 Code.
Lineage was a bare thread id, which is only unambiguous inside one server, so
the sidebar filed a child under a parent in its own environment or nowhere.
That ruled out the case people actually have: a child started on a machine that
is up, continuing work whose own host is down or busy.

A thread now records the environment its parent id belongs to. Null means "the
same environment", which is what every existing row already means, so nothing
is backfilled and a server that knows nothing about this keeps working. The
sidebar resolves parents by (environment, thread), the move-under picker offers
sessions on other machines while still refusing descendants across them, and
`t3_link_session` takes an optional parent environment.

The cycle guard is skipped for a parent named on another environment: that id
belongs to a graph this server has never seen, so walking it locally could only
match by coincidence. The client tree keeps its own depth and seen-set guards
for the lineages it stitches together.

A draft seeded from another session's context no longer drops its parent when
it moves to a different project — it records which environment that parent is
on instead. Moving a seeded draft somewhere the work can actually run, while
its parent's host is unreachable, is the whole point.

Model: Claude Opus 5 (1M context), harness: T3 Code.
Four gaps that all showed up as "the app looks broken" rather than "that
machine is down":

The thread header claimed "Syncing messages…" for a cached thread whose host
was unreachable, and spun on "Loading messages…" forever when it had no detail
yet. Nothing was syncing and nothing was going to load. A cached thread on an
unreachable host now reads as what it is — a cached copy — so the state is
legible instead of looking like a hang.

Search only ever asked the host, so work from a machine that went down became
unfindable. Searches now also cover the threads this device has cached, merged
behind the live results so a reachable host always leads. This is the honest
subset, not a replacement for the host's index: it cannot see a thread that was
never opened here.

A handoff could go to the clipboard or a new thread, but not out of the app.
"Save full context as file" writes the same digest to markdown — what survives
being sent to a colleague or kept while a machine is rebuilt.

Model: Claude Opus 5 (1M context), harness: T3 Code.
An earlier commit on this branch swept up a pre-existing local change to the
vendored alchemy gitlink. `.repos/` is read-only reference material and has
nothing to do with this work, so restore the pointer the base branch carries.

Model: Claude Opus 5 (1M context), harness: T3 Code.
Model: Claude Opus 5 (1M context), harness: T3 Code.
Making lineage cross environments changed `collectDescendantThreadIds` to
return (environment, thread) pairs, and the mobile drag validator was still
asking it about a bare thread id. The lookup never matched, so nesting a row
under its own descendant was allowed — the one thing that guard exists to stop.

`measureSubtreeDepth` had the same bare-id assumption: it bucketed children by
parent id alone, which can now associate rows across environments and misjudge
the depth a drop would produce.

Both now key on the same scoped pair the rest of the lineage code uses, exported
from client-runtime so there is one definition rather than three spellings.

Model: Claude Opus 5 (1M context), harness: T3 Code.
Third attempt at the same bug, so this time the reachability is verified
rather than assumed.

`HomeScreen` has an early return `if (threadListV2Enabled)`, and
`resolveThreadListV2Enabled` returns true unless a device opted into the
legacy list — so that return fires on every phone. My phase-sidebar branch sat
*after* it and was unreachable dead code. The toggle wrote its preference, the
pane existed, and nothing could ever render it.

Moved above that return. Verified order in HomeScreen is now: empty state →
phase sidebar → V2 list.

`ThreadNavigationSidebarPane` had the same shape of defect: it renders a thread
list from two paths (`props.nativeChrome` and the fallback) and I had gated
only the first, so the flag was half-wired on tablets too. Both paths now gate.

The pattern across all three failures is identical — I put code somewhere
plausible and never traced whether that line executes. Existence is not
reachability, and neither typecheck nor unit tests can tell the difference.

Verified: 280 tests across 31 files, `vp run typecheck` clean, lint clean, and
the return order in both files read back explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mpared

Three places still spoke the old bare-id dialect after lineage gained an
environment, each wrong in a way that only shows up once a parent actually
lives on another machine.

The client thread reducer dropped `parentEnvironmentId` from a re-parent event,
so a lineage moved across machines rendered correctly only after a refetch.
Mobile's drag re-parent omitted the field entirely — and omitted means
"unchanged", so a thread that used to hang off another machine would keep
pointing at that machine while carrying a local parent id. Its "already
parented here" check compared bare ids, so a same-id session elsewhere read as
a no-op and refused a real move.

The composer draft store compared drafts by parent id alone, which would treat
a draft re-targeted at another environment as unchanged and keep the stale
parent environment — the exact state the seeded-draft handoff depends on being
right.

Model: Claude Opus 5 (1M context), harness: T3 Code.
The sweep keeps a thread that is the parent of something still cached, so
evicting never orphans a lineage the sidebar is drawing. It built that parent's
key from the child's environment, which is only right when both sit on the same
machine — for a parent on another machine it protected a key nothing is stored
under, and left the real parent evictable.

The parent's environment is now read alongside its id, so the key names the
machine the parent is actually cached under.

Model: Claude Opus 5 (1M context), harness: T3 Code.
fix(mobile): render the phase sidebar before the list that always wins
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 19:33 — with GitHub Actions Active
feat(client): keep sessions, handoffs and lineage working while a host is down
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 19:45 — with GitHub Actions Active
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 20:39 — with GitHub Actions Active
fix(web): stop desktop startup from crashing
@tusharbhardwaj-bk
tusharbhardwaj-bk deployed to bk-desktop-staging September 1, 2026 20:55 — with GitHub Actions Active
@tusharbhardwaj-bk tusharbhardwaj-bk changed the title chore: promote expbkmain to bkmain (mobile phase sidebar, plan review, member tagging) chore: promote expbkmain to bkmain (host-down resilience, cross-machine lineage, mobile sidebar) Sep 2, 2026
@tusharbhardwaj-bk
tusharbhardwaj-bk merged commit 3ca5842 into bkmain Sep 2, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. 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