feat(desktop): bundle the backend into managed BK builds as an offline-capable local environment - #155
Merged
Merged
Conversation
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
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"
…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>
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. |
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.
Promotion of #153 (merged to
expbkmain, verified by Tushar on the staging desktop build0.0.35-staging-nightly.20260901.2).What this does
bk-local, same rails as a WSL secondary); the central server stays primary, so member tagging/roster/identity are unchanged.apps/web/src/fork/managedOfflineGate.ts); revoked/expired pairings still hit the pairing gate.desktopLocal.tsreturns to upstream byte-for-byte; the client-only build branches are deleted.Notes for this deploy
t3-bkmain.serviceand kills live agent sessions on bkt3.bk-desktop-productionenvironment and may wait for reviewer approval.150→450 MB (ZIP 133→142 MiB) since it carries the server tree again.Built by Claude Fable 5 on Claude Code.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.