21 KiB
Android Projects / Diff / Worktree screens (client parity)
Feature id:
w5-android-parity· Branch:develop· Server changes: ZERO (every route below already ships from Wave 1-4).
Reality check (read before planning — the task's premise is stale)
The task brief says the Android UI modules are "SDK-gated / commented out in settings.gradle.kts". That is no longer true on develop. Verified on disk:
android/settings.gradle.kts:50-53includes:app,:terminal-view,:host-registry,:client-tls-android(uncommented). The// TODO(android-sdk)gating the README (android/README.md:20-27) still describes is stale doc drift.- The SDK is installed and proven:
android/local.properties→sdk.dir=/usr/local/share/android-commandlinetools;platforms/{android-35,android-36}present.PROGRESS_ANDROID.md:358-382records a full green gate (./gradlew test :app:assembleDebug koverVerify→ ~484 tests, APK builds). The whole Android client (A1–A36) is committed (e254918). :api-clientis not empty (only the.gitkeepplaceholders remain beside real code):models/Projects.kt,routes/{ApiClient,Endpoints,ApiRoute}.ktetc. exist.
So the actual parity gap is not "bring modules online" — it is: the shipped Android client was built to iOS P0+P1, which deliberately excluded the git-write surface. ANDROID_CLIENT_PLAN.md:79 — "worktree-create — server has routes the iOS client does not consume; out of parity scope"; :233 lists POST /projects/worktree among routes "explicitly NOT consumed"; :218 scopes diff to staged=1|0 only (no ?base=); :221-227 lists only 3 guarded routes (kill / hook.decision / put-prefs). Everything Wave 1-4 added on the web/server is missing on Android:
| Server route (exists) | src/server.ts | Android today | Gap |
|---|---|---|---|
GET /projects/diff?…&base=<rev> |
811-837 | :app HttpDiffFetcher sends staged only (DiffViewModel.kt:313-322) |
base-diff |
GET /projects/log?path=&n= |
843-861 | — | recent commits |
GET /projects/pr?path= |
871-887 | — | PR + CI chip |
POST /projects/worktree |
908-934 | worktrees shown read-only (ProjectDetailScreen.kt:116-159) |
create |
DELETE /projects/worktree |
937-964 | — | remove |
POST /projects/worktree/prune |
967-986 | — | prune |
POST /projects/git/{stage,commit,push} |
997-1096 | Diff is inert read-only | stage/commit/push |
GET /projects sync fields ahead/behind/lastCommitMs |
types.ts:337-339 | ProjectInfo (Android) lacks them |
sync chip |
This plan closes exactly that gap. It is the largest Wave 5 item but narrower than "build the client" — the foundation (grouping, prefs round-trip, detail page, diff flatten/render, adaptive nav, mTLS transport, design system) is done and reused verbatim.
Contract (routes / messages / env / types)
No new server routes, no env vars, no :wire-protocol (frozen) changes. All additions are Kotlin client code.
Route home decision (security-driven — do not deviate)
The Origin header (CSWSH 铁律) is stamped in exactly one place: ApiRoute.toHttpRequest() when originPolicy == GUARDED (android/api-client/.../routes/ApiRoute.kt:61-71; ARCHITECTURE §4.3, checklist :467). Therefore every state-changing route MUST be added to :api-client (worktree create/remove/prune, git stage/commit/push) so it flows through that single Origin-stamping point. The RO additions (/projects/pr, /projects/log) also go in :api-client for consistency with the existing RO methods (ApiClient.kt:34-96). The one exception is diff-vs-base: the diff route already lives self-contained in :app (DiffViewModel.kt — A24 put it there because it is read-only and needs no Origin), so extend it in place rather than churning it into :api-client.
New :api-client route builders — routes/Endpoints.kt (mirror the existing 26-93)
// RO (no Origin)
fun projectPr(path: String): ApiRoute // GET /projects/pr?path=<enc> READ_ONLY
fun projectLog(path: String, n: Int?): ApiRoute // GET /projects/log?path=<enc>[&n=<int>] READ_ONLY
// G (Origin byte-equal) — bodies are application/json, encoded via ModelJson
fun createWorktree(path, branch, base: String?): ApiRoute // POST /projects/worktree
fun removeWorktree(path, worktreePath, force: Boolean): ApiRoute // DELETE /projects/worktree (BODY on DELETE — see Edge cases)
fun pruneWorktrees(path): ApiRoute // POST /projects/worktree/prune
fun gitStage(path, files: List<String>, stage: Boolean): ApiRoute // POST /projects/git/stage
fun gitCommit(path, message: String): ApiRoute // POST /projects/git/commit
fun gitPush(path): ApiRoute // POST /projects/git/push
Reuse the existing percentEncode (strict RFC 3986 unreserved, Endpoints.kt:102-120) for path/n query values and ModelJson.encodeToString for bodies. n clamps client-side to 1..GIT_LOG_MAX mirror (server re-clamps; send it raw-but-bounded).
New :api-client models (models/, all @Serializable, tolerant decode via ModelJson / LossyDecode, mirror types.ts)
PrStatus+PrAvailability(string-union → enum with unknown→ERROR) +PrCheckSummary— types.ts:560-588. Every field exceptavailabilityoptional;availabilityunknown/missing →ERROR(never throw).CommitLogEntry { hash, at: Long, subject }+GitLogResult { commits, truncated }— types.ts:695-704 (list-lossy: drop malformed commit, keep rest).GitWriteOutcome— a client result union for the guarded git ops carrying the server's safe body:Ok(payload)vsRejected(status, message). Server always returns{ ok, … }on 200 and{ ok:false, error:"<safe string>" }/{ error }on failure (git-ops.ts:81-119, worktrees.ts:201-341 — never raw stderr). Decodeerroras an inert string to display.- stage 200:
{ ok, staged: Boolean, count: Int } - commit 200:
{ ok, commit: String }(short sha; may be"") - push 200:
{ ok, branch, remote } - worktree create 200:
{ ok, path, branch }; remove 200:{ ok, path }; prune 200:{ ok, pruned: List<String> }
- stage 200:
- Extend
models/Projects.ktProjectInfowithahead: Int?,behind: Int?,lastCommitMs: Long?(types.ts:337-339). Extend the:appDiffResult(DiffViewModel.kt:215) withbase: String? = null(types.ts:553).
New ApiClient methods (routes/ApiClient.kt) + status mapping
suspend fun projectPr(path): PrStatus— 200→tolerant decode; 400→ProjectPathInvalid; 404→ProjectNotFound; elseUnexpectedStatus. (PR degrade — gh missing/unauth/no-PR — isavailabilityinside a 200 body, not an HTTP status.)suspend fun projectLog(path, n): GitLogResult— 200→decode; 400→ProjectPathInvalid; 404→ProjectNotFound; 500→ProjectDetailUnavailable(reuse) or newGitLogUnavailable.- Guarded ops return
GitWriteOutcome: 200→Ok(...); 403→Rejectedreading body.error (disabled and Origin-fail both 403 — see Security); 400/404/409→Rejected(status, body.error); 429→RateLimited; elseUnexpectedStatus. NewApiClientErrorcases as needed:WorktreeDisabled,GitOpsDisabledare not distinguishable from Origin-403 by status alone → prefer surfacingRejected(status, safeMessage)over inventing typed variants (ApiClientError.kt:11-48is the sealed set to extend minimally).
:app presenters (plain, JVM-testable — same pattern as DiffViewModel/ProjectDetailViewModel, NOT androidx.lifecycle.ViewModel)
WorktreeViewModel(gateway, repoPath):create(branch, base?),remove(worktreePath, force),prune(). Each is a phase machine (Idle/Working/Done/Failed(message)) that re-fetches project detail on success so the worktree list refreshes. Branch name validated client-side (mirrorvalidateBranchName, worktrees.ts:95) before any I/O.- Extend
ProjectsGateway(ProjectsViewModel.kt:400-413) with the new gateway methods;ApiClientProjectsGatewaydelegates to the newApiClientmethods. (Keeps the VM JVM-tested against a fake.) - Extend
DiffViewModel(DiffViewModel.kt:47): addbase: String?state +setBase(rev); whenbase != nullthestagedtoggle is suppressed (server ignoresstagedwhenbaseset — server.ts:831 / public/diff.ts:112-129). ExtenddiffUrl(:313) to append&base=<enc>and omitstagedin base mode. Add stage/commit/push:toggleStage(file, staged),commit(message),push()via the guarded gateway; these are only offered in working/staged mode, never base mode (parity with public/diff.ts:321). PrChipState(fold intoProjectDetailViewModelor a smallPrViewModel): fetch/projects/prlazily on detail load; render one chip.
Files to change
| Path | Concrete change |
|---|---|
android/api-client/.../routes/Endpoints.kt |
Add 8 builders (pr, log, worktree×3, git×3). RO ones READ_ONLY; write ones GUARDED with ModelJson bodies. Reuse existing percentEncode/HEX. |
android/api-client/.../routes/ApiClient.kt |
Add projectPr, projectLog, createWorktree, removeWorktree, pruneWorktrees, gitStage, gitCommit, gitPush with the status mapping above. |
android/api-client/.../routes/ApiClientError.kt |
Add minimal cases only if needed (GitLogUnavailable); prefer Rejected(status,msg) carried in GitWriteOutcome over per-error variants. |
android/api-client/.../models/PrStatus.kt (new) |
PrStatus/PrAvailability/PrCheckSummary tolerant. |
android/api-client/.../models/GitLog.kt (new) |
CommitLogEntry/GitLogResult list-lossy. |
android/api-client/.../models/GitWrite.kt (new) |
GitWriteOutcome + per-op payloads; serializers registered in models/Serializers.kt if custom. |
android/api-client/.../models/Projects.kt |
Add ahead/behind/lastCommitMs to ProjectInfo. |
android/app/.../viewmodels/WorktreeViewModel.kt (new) |
Create/remove/prune phase machine; branch validation; re-fetch detail on success. |
android/app/.../viewmodels/ProjectsViewModel.kt |
Extend ProjectsGateway + ApiClientProjectsGateway with the new methods; add sync-chip copy to ProjectsCopy. |
android/app/.../viewmodels/ProjectDetailViewModel.kt |
Compose in worktree actions + PR chip fetch + recent-commits fetch (each independent, failure-isolated). |
android/app/.../viewmodels/DiffViewModel.kt |
Add base mode (+setBase), extend diffUrl with &base=, add toggleStage/commit/push via a guarded GitWriteGateway; extend DiffResult with base. |
android/app/.../screens/ProjectDetailScreen.kt |
Add: "New worktree" branch-input sheet (+optional base), per-worktree remove (force-confirm dialog, block isMain), prune button, a PR chip (tappable only if url parses https), a recent commits section. Keep all server strings inert Text (§8). |
android/app/.../screens/DiffScreen.kt |
Add a base-rev input (a third mode beside Working/Staged), per-file Stage/Unstage buttons in working/staged only, a commit-message field + Commit/Push buttons with result banners. |
android/app/.../screens/ProjectsScreen.kt |
Optional: render sync chip (ahead/behind) on project cards from the new ProjectInfo fields. |
android/app/.../nav/NavGraph.kt |
The diff/detail routes already exist (:94-131); no new route needed — worktree/commit UIs are in-place sheets/dialogs, not new destinations. Verify ProjectDetailPane/DiffPane pass the new gateway. |
android/app/.../designsystem/* |
Reuse existing tokens (WebTermColors.statusWorking/statusStuck, WebTermCard, Spacing). PR-check pass/fail/pending → existing status colors. No new tokens. |
| test files (see TDD) | New/extended JVM unit tests under api-client/src/test/... and app/src/test/.../viewmodels/. |
android/README.md |
Fix the stale "SDK-gated / COMMENTED OUT" section (:12-27) to match reality. |
docs/PROGRESS_LOG.md |
Orchestrator appends the W5 entry (not the builder). |
TDD steps (ordered — JUnit5 + runTest/StandardTestDispatcher + Turbine + MockK, matching DiffViewModelTest/ApiRouteShapeTest)
Write test → RED → implement → GREEN, bottom-up (pure :api-client first, then :app presenters, Compose deferred to device QA per plan §7).
Phase A — :api-client routes + models (fully green + Kover ≥80% in one pass)
models/PrStatusTest.kt— decode a fullavailability:"ok"body; unknownavailability→ERROR; missing fields tolerated;PrCheckSummarycounts; non-object → error/availability=ERROR(mirrorHappyPathDecodeTest/TolerantDecodeTest).models/GitLogTest.kt— decode{commits,truncated}; drop a commit missinghash/at, keep siblings;truncatedpassthrough.models/GitWriteTest.kt— decode each 200 payload; decode failure body{ok:false,error:"…"}→Rejected(status,message).routes/GitRouteShapeTest.kt(extendApiRouteShapeTest) — Origin-iff-guarded:projectPr/projectLogcarry no Origin; worktree/git-write carryOrigin == endpoint.originHeaderbyte-equal; assert method+path+query encoding (?path=strict-encoded;&n=); assertDELETE /projects/worktreecarries a JSON body.routes/ApiClientGitTest.kt— withFakeHttpTransportqueue canned responses: pr 200/400/404; log mapping; each guarded op 200→Ok, 403→Rejected(body.error), 429→RateLimited, 409→Rejected. Assert the fake received the Origin header on writes and not on reads.- Run
./gradlew :api-client:test :api-client:koverVerify→ green.
Phase B — worktree + diff-base presenters (:app, green)
7. viewmodels/WorktreeViewModelTest.kt — invalid branch name → Failed with no I/O; create success → Done + detail re-fetch invoked; remove of isMain blocked; force flag threaded; 403-disabled → Failed(safeMessage); 429 → rate-limited copy.
8. Extend DiffViewModelTest.kt — setBase("main") builds …/projects/diff?path=…&base=main with no staged; base mode hides the staged toggle; diffUrl percent-encodes base; base-mode disables stage/commit/push.
Phase C — git-write from diff + PR/log (:app, green)
9. Extend DiffViewModelTest.kt — toggleStage(file,true) posts {path,files:[newPath],stage:true} and refreshes; commit("msg") → Ok(sha) banner; empty message rejected client-side or surfaces server 400 message; push() → Ok(branch,remote); 409 push-rejected → inert server message.
10. viewmodels/ProjectDetailPrLogTest.kt — PR fetch failure does not fail the detail load (isolated); availability != ok renders degraded copy; recent-commits list decodes + isolates its own error.
11. Run ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify → green.
Device QA (deferred — no emulator/Firebase/host here; append to android/DEVICE_QA_CHECKLIST.md): worktree-create sheet + list refresh, remove force-confirm dialog, Stage/Unstage button layout, commit/push banners, PR chip tap→browser (https-only), base-rev input, sync-chip rendering, adaptive-pane behavior.
Edge cases
- DELETE with a request body.
DELETE /projects/worktreereadsreq.body(server.ts:943-945), but the current Android transport has only tested body-less DELETE (killSession). VerifyOkHttpHttpTransportactually transmits a body on DELETE (some stacks drop it) — add a transport-level test if not covered. This is the single highest-risk integration gotcha. - 403 is overloaded.
requireAllowedOriginfailure andworktreeEnabled==false/gitOpsEnabled==falseboth return 403 (server.ts:909-911, 938-939, 998-1000). The client cannot tell them apart by status → must decodebody.errorand surface it inertly; do not assume 403 means "re-pair / bad Origin". - No capability-discovery endpoint.
GET /config/uiexposes onlyallowAutoMode/costBudgetUsd(server.ts:1099-1106) — it does not reportworktreeEnabled/gitOpsEnabled/GH_ENABLED. So the client discovers "disabled" only via a 403 at action time; after a disabled-403, hide/disable that control for the session. - PR degrade lives in the body, not the status.
ghmissing/unauth/no-PR all return 200 withavailability∈{not-installed,unauthenticated,no-pr,disabled,error}(types.ts:560-566). Render one chip fromavailability; never treat non-okas an HTTP error. stagedvsbaseprecedence. Whenbaseis set the server ignoresstaged(server.ts:831); mirror by omittingstagedand hiding the toggle in base mode (public/diff.ts:112-129).- Rate limits (429). stage/commit share
gitWriteLimiter; push has a tightergitPushLimiter(server.ts:1003, 1075). SurfaceRateLimitedcopy; do not auto-retry. - Empty commit sha.
git commitmay return{ok:true, commit:""}(git-ops.ts:252) — render "committed" without a sha, don't crash on empty. - Base-rev injection.
?base=must be inert and pass the server'sisPlausibleRev(server.ts:826); the client sends it percent-encoded and lets the server 400 junk — surface that 400's message. - Detail refresh race. A worktree create/remove followed by a detail re-fetch can race a concurrent poll; cancel the in-flight fetch (the
job?.cancel()pattern inDiffViewModel.kt:78-82). - Force-remove of dirty worktree. Server 409 "uncommitted changes — force required" (worktrees.ts:278) → the remove dialog must offer an explicit Force re-confirm, and must block
isMainclient-side (worktrees.ts:328). - Tolerant decode everywhere. A partial/malformed PR/log/diff body must degrade (drop-one-keep-rest), never throw — reuse
LossyDecode/ModelJsondiscipline (ApiClient.kt:26-28).
Security
- Origin 铁律 preserved. All six write routes go through
ApiRoute.toHttpRequestGUARDED stamping (ApiRoute.kt:61-71) — the single CSWSH defense (ANDROID_CLIENT_PLAN:467). The two new reads (pr/log) MUST NOT carry Origin. A route-shape test asserts both directions (test step 4) so a future reclassification goes red, not silently wrong. - Untrusted server strings stay inert. Branch names, worktree paths, commit subjects, PR titles, diff lines,
errormessages are rendered as plain ComposeText— nolinkify/Markdown/AnnotatedStringautolink (ANDROID_CLIENT_PLAN:476, plan §8). The PR chip is the one exception: a tappable link only whenurlparses ashttps— validate scheme before making it clickable (:476). - No credential leakage / no cache. All calls ride the shared mTLS
OkHttpClientwith.cache(null)(:470) — diff/commit bodies can contain secrets; never persist them. - Client-side pre-validation (defense in depth). Branch name (
validateBranchNamemirror), non-empty commit message,isAbsoluteCwdfor any path minted into an action (ProjectsViewModel.kt:139-143precedent). The server re-validates + realpath-contains regardless (git-ops.ts / worktrees.ts) — the client checks are UX, not the security boundary. - Never log worktree paths, commit messages, PR titles, or error bodies.
Effort & dependencies
- Effort: L (largest Wave 5 item), ~7-9 pd for the buildable+tested layer — below the brief's 8-12 pd because the client scaffold, grouping, prefs round-trip, diff flatten/render, adaptive nav, mTLS transport and design system already exist and are reused verbatim (the app already builds to an APK). The remaining work is ~8 REST routes + ~6 models + 3-4 presenters + ~4 screen edits + ~40-50 tests. Add device-QA time separately (not doable in this env).
- Dependencies: none external — every server route shipped in Wave 1-4 (server.ts:811-1096), so this is a pure client pass with zero server change. Depends only on the existing frozen
:wire-protocolHostEndpoint/HttpTransportand:api-client(both stable).developmust contain the Android tree frome254918(it does). - Can one builder pass get a full green Gradle build? Yes for the logic+compile layer, but phase it into 3 green checkpoints — do not attempt it monolithically. A single pass can realistically finish Phase A (api-client) entirely green with Kover ≥80%. Phases B and C compile (
:app:assembleDebug) and pass JVM unit tests, but their Compose surfaces (sheets, dialogs, buttons, chip, banners) are device-QA-deferred by definition (plan §7 — no emulator/Firebase/host in this env), exactly as every prior Android wave deferred rendering. So a realistic single-builder deliverable is:./gradlew test :app:assembleDebug koverVerifygreen, with the interactive git-write flows (create a worktree, stage→commit→push from a phone, tap a PR chip) recorded inDEVICE_QA_CHECKLIST.mdrather than proven here. Recommended phasing: A (api-client routes/models/tests) → B (worktree + diff-base presenters) → C (git-write-from-diff + PR/log + screen wiring), each ending on a green build so review and rollback stay cheap. The stage→commit→push-from-the-diff-viewer flow (Phase C) is the largest and riskiest single chunk; keep it last and isolated. - First action for the builder: run
cd android && ./gradlew test :app:assembleDebugto confirm the baseline is green ondevelopbefore adding anything (the toolchain is proven but unverified on this exact checkout), and fix the staleREADME.mdno-SDK section as part of the pass.