Skip to content

ADFA-4128 (11/11): app + bench — wiring Quick Build into the IDE and the benchmark harness - #1723

Open
fryanpan wants to merge 14 commits into
feature/ADFA-4128-qb-10-gradle-pluginfrom
feature/ADFA-4128-qb-11-app
Open

ADFA-4128 (11/11): app + bench — wiring Quick Build into the IDE and the benchmark harness#1723
fryanpan wants to merge 14 commits into
feature/ADFA-4128-qb-10-gradle-pluginfrom
feature/ADFA-4128-qb-11-app

Conversation

@fryanpan

@fryanpan fryanpan commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Part 11/11 of the stacked split of #1669 (requested by Akash). Base: feature/ADFA-4128-qb-10-gradle-plugin. Stack overview + review mechanics: PR 1 (#1713). Terms are defined in quickbuild/README.md (lands in PR 1).

Puts Quick Build in front of the user: a button next to Run, and enough narration to tell what it is doing and when it has finished. It also adds a harness to make it easier to run standard Gradle build and Quick Build benchmarks, and to gather key metrics about the stages of the build process.

flowchart TB
    subgraph appc["<b>This PR: inside app/ — wiring and bench</b>"]
        act["QuickBuildAction<br/>registered only when<br/>FeatureFlags.isExperimentsEnabled<br/><i>QuickBuildAction.kt</i>"] --> mgr["QuickBuildManager<br/>session lifecycle, provisioning,<br/>stop-tap cancellation"]
        mgr --> narr["QuickBuildOutputNarrator<br/>attached to the session manager;<br/>queues while no pane is bound<br/><i>QuickBuildOutputNarrator.kt</i>"]
        mgr --> sb["status bar collector<br/>lifecycle-scoped: state, not history<br/><i>QuickBuildStatusBar.kt</i>"]
        koin["QuickBuildModule (Koin)<br/>binds every core port;<br/>assetsLiveReloadable read once<br/>at the Android edge<br/><i>QuickBuildModule.kt</i>"]
        tr["bench trampoline activity<br/>debug-source-set manifest only<br/><i>QuickBuildBenchActivity.kt</i>"] --> mgr
        mgr --> hooks["QuickBuildBenchHooks<br/>inert release twin<br/><i>debug/QuickBuildBenchHooks.kt</i>"]
        hooks --> rec["event + metrics recorders"]
        rec --> log["bench-events.jsonl<br/><i>BenchEventsFile.kt</i>"]
        hooks --> e2e["MODE_STANDARD_E2E<br/>measures the standard build<br/>through install + launch<br/><i>QuickBuildBenchAutostart.kt</i>"]
    end
    adb["adb shell am start<br/>gated on android.permission.DUMP"] --> tr
    mgr --> core[":quickbuild:core session manager (PRs 5-8)"]
    narr --> pane["Build Output pane (existing)"]
    sb --> bar["bottom status bar (existing)"]
    mgr -- "provisioning + rebuild builds" --> gbs["GradleBuildService (existing)"]
    classDef thisPrBox fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
    classDef inPr fill:#ffffff,stroke:#64748b,color:#000
    class appc thisPrBox
    class act,mgr,narr,sb,koin,tr,hooks,rec,log,e2e inPr
Loading

What to review

  • QuickBuildAction.kt — owns the session tap: start, stop-tap cancel, grey-out. Line-by-line.

  • Gradle tuning — Metaspace 192→384 MB + daemon idle timeouts (30 min balanced / 2 h high-perf); the only changes to non-QB behavior

  • QuickBuildOutputNarrator.kt, QuickBuildStatusBar.kt — queued narration; lifecycle-scoped status showing state, not history.

  • QuickBuildModule.kt — binds every core port; reads assetsLiveReloadable at the Android edge.

  • GenerateSourcesDeferral.kt — defers resource-XML generateSources until Quick Build goes idle.

  • John's items C15, C16, C17, C22, C23 folded in as fixes.

  • Rollback: without the flag there is no UI entry point.

  • Followup, not fixed: R8 emits kotlin.Metadata warning noise.

  • QuickBuildBenchAutostart.kt — MODE_STANDARD_E2E measures the standard build through install and launch. Line-by-line.

  • The e2e latch bypasses install confirmations only for the measured span.

  • QuickBuildBenchActivity.kt, QuickBuildBenchHooks.kt — DUMP-gated trampoline; inert release twin.

  • BenchEventsFile.kt — a failed relaunch omits relaunchOk rather than recording zero.

How this PR Was Tested

  • Automated tests (see coverage details below)

  • Manual QA — walked the manual-qa.md test plan on the A56 [measured on a56]

  • Benchmark — measured on real devices, both arms: a warm code edit reaches the running app with about a 5x median speedup over a standard build + deploy. The weaker the phone, the bigger the win. MODE_STANDARD_E2E drove the standard arm through install and launch.

  • Still open — the rebaseline relaunch path is not yet device-verified, and neither is the API 28/29 resource-swap success path.

Coverage (JaCoCo at the stack tip, single run):

A lot of this was UI code and wasn't covered very well by automated tests.

Package Line Branch Note
actions/build 0.5% 0.0% UI — device-tested
actions/file 0.0% 0.0% UI — device-tested
activities/editor 0.9% 1.8% UI — device-tested
analytics/quickbuild 49.6% 30.4%
app 0.7% 0.5% Application classes, Android-bound
di 0.0% 0.0% Koin wiring
fragments/sidebar 0.0% 0.0% UI — device-tested
handlers 7.6% 0.0% Android-bound
quickbuild 27.3% 24.3% mixed UI/logic; see caveat
services/builder 6.8% 0.0% bound Service, Android-bound
utils 0.0% 0.0% mixed UI/logic
viewmodel 0.9% 0.0% UI — device-tested
TOTAL (all app files in this PR) 16.9% 15.3% 2,989 lines, 1,582 branches

Review fixes (2026-08-22)

A review-fixes commit addresses the code-review findings. Two changes here deliberately ship to all users, with the Experiments flag off (approved):

  • GradleBuildTuner. Benefit: builds stop dying with out-of-memory errors on ordinary phones, and a rebuild shortly after a build is much faster. Two settings on every Gradle build: (a) the Metaspace cap (JVM memory for loaded class definitions) goes 192 MB -> 384 MB -- real Android-plugin builds exceed 192 and were being killed mid-build; it's a cap, not a reservation, so no extra memory is used unless the build needs it. (b) The Gradle daemon (the background process that keeps builds warm) stays alive for a time matched to the phone's tier -- 15 min low-memory, 30 min balanced, 2 h high-performance -- so a quick rebuild skips the cold start while weak phones don't host a resident daemon for hours.
  • generateSources narrowing. Benefit: fewer surprise build stalls while editing, less battery and CPU burned. The IDE used to launch a Gradle generate-sources step after every save-all and any XML save, even when nothing that step produces could have changed. Now it runs only when the saved file can actually affect generated sources. Same results, far fewer builds.

One candidate followup from review (orchestrator forcing a full-changed compile after a failed dex/deploy) was re-checked and refuted at this tip: the forced flag re-arms and a forced no-op already performs the full rebuild. The daemon-side recovery lever stays in as defense in depth.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 5b48f90 to c69d8ef Compare August 22, 2026 06:41
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from c69d8ef to 5a3d5eb Compare August 22, 2026 07:06
@fryanpan
fryanpan marked this pull request as ready for review August 23, 2026 02:31

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 5a3d5eb to ac3ab4e Compare August 24, 2026 14:44
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from ac3ab4e to a45a359 Compare August 24, 2026 14:48
@fryanpan

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review skipped: 106 files exceed the limit of 100.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from a45a359 to 7c72105 Compare August 27, 2026 17:32
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch 2 times, most recently from 3e8d9d2 to 6254159 Compare August 29, 2026 23:17

@jatezzz jatezzz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan — review of the Quick Build app wiring. Seven findings; two are worth fixing before merge and are left as inline comments:

  • ProjectHandlerActivity.kt:549 — the reArmInstall safety net does not cover the clobber dialog, so a rotation while it is up loses the install silently.
  • QuickBuildStatusBar.kt:151 — a landed build is re-announced on every re-subscribe and, with onlyIfOwned = false, stomps project-init / plugin-install status.

The remaining five are low: the session teardown on a transient APK parse failure, the null-activity path that builds stale content, the ellipsized actionable status copy, and three unused imports that should fail spotlessCheck.


One more (low), which could not be left inline because the file is not in this diff:

app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt:43 — a missed sibling of this PR's raw-vs-user-visible split.

This PR moved every UI decider over to isUserVisibleBuildInProgressAbstractCancellableRunAction, ProjectHandlerActivity.onResume, the progress bar, BuildVariantsFragment — but BaseBuildAction.prepare still reads the raw flag:

enabled = buildService?.let { !it.isBuildInProgress } == true

With the experiments flag on, Quick Build's eager prebuild now runs on every project open, so RunTasksAction (and any other direct BaseBuildAction) is silently greyed out for its whole duration with no explanation — unlike QuickBuildAction, which relabels, or QuickRunAction, which flashes msg_build_slot_busy. Note that AbstractCancellableRunAction.prepare unconditionally re-sets enabled = true, which is why its new slot-busy flash is reachable and these are not.


Checked and cleared: all new string/drawable/menu resources resolve on the head branch; ProjectManagerImpl.generateSources() returns Boolean, so GenerateSourcesDeferral's refusal-retry contract holds and a throw is treated as a refusal rather than cancelling the scope; InternalBuildBracket releases strictly after isBuildInProgress clears, so there is no window where isUserVisibleBuildInProgress reads true for an internal build; QuickBuildOutputNarrator confines its mutable state to one Dispatchers.Main.immediate scope and compares sink identity correctly; QuickBuildReloadTimingMetric.asBundle() is 25 params worst case, within the Firebase cap; ThermalSafeStrategy copies GradleDaemonConfig, so the new non-defaulted daemonIdleTimeoutMs is safe and 2h fits in Int; and InstallationResultHandler.onResult returning null is handled as "do nothing" by its only caller.

if (isDestroyed || isFinishing) {
return@launch
}
dispatched = true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan medium — the re-arm safety net does not cover the clobber dialog.

dispatched = true is set before the dialog is shown, so finally { if (!dispatched) buildViewModel.reArmInstall(state) } treats "dialog opened" as "install dispatched".

Scenario: a Standard Run build finishes, the "Replace the app installed for this project?" dialog appears, the user rotates the device. The dialog is dismissed along with the activity, onConfirm never fires, doInstallApk never runs, and nothing re-arms AwaitingInstall. A successful build ends with no install and no message — the exact failure mode the re-arm was added to prevent.

Suggest keeping dispatched = false until a decision is actually reached, so the net also covers "dialog shown but dismissed without a decision".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: still open at head (5f92da7) - dispatched = true is still set before the dialog is shown.

Downgrading this from medium, because the scenario named here does not reproduce: EditorActivityKt declares android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|fontScale", so neither a rotation nor a font-scale change recreates the activity, and the dialog survives both.

What remains is narrower: process death, "Don't keep activities", or a config change outside that list. On those paths the coroutine has already completed normally (showing the dialog does not suspend), so finally ran with dispatched = true and nothing re-arms AwaitingInstall. Moving dispatched = true into the two decision callbacks still closes it.

): QuickBuildStatusBarUpdate? =
when {
// A duration means a build landed - the moment BUILD FAILED must be overwritten.
current.buildDurationMillis != null -> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan medium — a landed build is re-announced on every re-subscribe, and it stomps other status.

This current.buildDurationMillis != null branch is tested before the previous == null branch and emits Show(..., onlyIfOwned = false).

The status collector runs in repeatOnLifecycle(STARTED) with a local previousStatus reset to null on every re-subscribe, and QuickBuildStatus is a StateFlow, so it replays. Every time the user returns to the editor after a Quick Build has landed, the bar re-writes "Quick Build: reloaded to generation N in 2.3s" for a build that finished long ago — and because onlyIfOwned = false, it clobbers whatever took the bar in the meantime (project-init / plugin-install status).

That directly contradicts the KDoc above, which says "a 'Project initialized' message is not stomped by a session that has nothing to say."

Fix either way: require previous != null for the landed-build branch, or pass onlyIfOwned = true when previous == null.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: still open at head, and it fires on the primary Quick Build loop rather than only on activity recreation - raising this from medium.

Traced end to end: previousStatus is declared inside the launch{} within repeatOnLifecycle(STARTED), so it resets to null on every onStart. quickBuildTransition(null, UpToDate) returns Settled (QuickBuildTransitions.kt:150), and upToDateUpdate tests buildDurationMillis != null before previous == null, emitting Show(..., onlyIfOwned = false).

The duration persists, which is what makes it reachable: SessionReducer has no transition out of Deployed except a new build, and QuickBuildStatus.from(Deployed) carries buildDurationMillis (QuickBuildStatus.kt:155). So the resting StateFlow value after an ordinary reload still holds it.

That makes the trigger the core loop - switch to the proxy app to watch the reload, switch back - and the bar rewrites "reloaded ... in 2.3s" for a build that landed minutes ago, clobbering whatever took the line meanwhile. No test covers update(null, UpToDate(gen, duration != null)); the nearest one passes duration = null.

// The Quick Build session's installed baseline is about to be replaced; stop it.
// Keyed off the re-check rather than off whether a dialog was shown: a tap that
// already confirmed this exact clobber skips the dialog but still clobbers.
if (now != QuickBuildClobberConfirmation.NotNeeded) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan low — a transient APK parse failure tears down a healthy session.

onProceed calls restartSession() whenever now != NotNeeded, but NeededForUnknownAppId is produced by any runCatching failure in apkApplicationId — including a transient packageManager.getPackageArchiveInfo failure on the freshly built APK.

Only is Needed actually asserts that the proxy app occupies the slot. As written, a parse hiccup kills a live, healthy Quick Build session for no established reason. Consider restarting only on is Needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: still open at head - onProceed still restarts on now != NotNeeded, and apkApplicationId still funnels every runCatching failure into NeededForUnknownAppId. Confirming the grade as written: a transient parse failure on a freshly built APK tears down a live, healthy session, and only is Needed actually evidences an occupant.


val activity = data.getActivity()
if (activity == null) {
sessionManager.onQuickBuildTapped()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan low — the null-activity path skips the save-all flush and builds stale content.

When data.getActivity() returns null this calls sessionManager.onQuickBuildTapped() directly, bypassing the save-all flush and passing the default wroteSomething.

That is exactly the "build silently uses stale on-disk content while the editor shows the user's edit" case the surrounding ~20 lines of comment exist to prevent, and it also feeds the session's armed-switch heuristic the wrong signal. Returning false (or flashing, as the other paths do) would be safer than building stale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: still open at head, with a second bypass worth naming: the null-activity path also skips ensureQuickBuildClobberConfirmed, which the normal path applies further down. So it can replace whatever holds the project's applicationId with no confirmation at all - not just build stale content.

Holding it at MINOR because no current caller reaches it. The only ActionData this action sees comes from EditorHandlerActivity.createToolbarActionData(), which is ActionData.create(this) with this the activity, so getActivity() is never null on the toolbar, dropdown or overflow paths. Still worth making that explicit (?: return false) rather than leaving a branch that silently does the unsafe thing if a future call site builds its data differently.

android:id="@+id/statusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan low — actionable copy gets truncated; fails the 2x font-scale bar.

statusText is maxLines="1", and this diff adds ellipsize="end" to it — then multi-sentence actionable copy is routed through it, e.g. quick_build_status_app_not_running ("Quick Build: built. Your app is not running - tap Quick Build to start it with your changes.") and quick_build_status_needs_full_build.

At 2x font scale on a phone this truncates well before the instruction, so the one surface QuickBuildStatusBar's KDoc says exists to name the remedy cannot show the remedy. CLAUDE.md reserves maxLines/ellipsize for text that is genuinely disposable; these strings are not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: still open at head, and it bites at font scale 1.0, not only 2x - raising this from low.

quick_build_status_app_not_running is 92 characters. At 14sp BodyMedium on a ~360dp-wide phone, less the 16dp + 16dp padding, roughly 50-58 characters fit on one line - so the sentence naming the remedy ("tap Quick Build to start it with your changes") is cut at the default font scale, before accessibility settings enter the picture. quick_build_status_deploy_failed (65 chars) and quick_build_status_needs_full_build (59) overflow too.

maxLines="1" is pre-existing; what this PR adds is ellipsize="end" plus the long actionable copy routed through it. Dropping maxLines/ellipsize here, or raising maxLines to 2-3, would let the remedy show.

Comment on lines +139 to +140
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode.MAIN

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan low — unused imports; spotlessCheck should fail on this.

Neither Subscribe nor ThreadMode.MAIN is used — there is no @Subscribe member anywhere in this file. ktlint's standard:no-unused-imports is on by default and this file is in the Spotless ratchet, so CI should reject it. Worth re-running spotlessApply against the final state of the branch (see also the unused Position import in EditorHandlerActivity.kt).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: the imports are still present and still unused - Subscribe and MAIN each occur exactly once in the file, on their own import lines, and the @Subscribe EventBus member is on BaseEditorActivity (onInstallationResult), not here.

One correction: CI does not reject this. .github/workflows/debug.yml:216 runs spotlessCheck inside the "Build Universal APK" job, and all three checks are green at 5f92da7. So neither the ratchet nor the pre-push hook removes these - they need deleting by hand.

import com.itsaky.androidide.models.FileExtension
import com.itsaky.androidide.models.OpenedFile
import com.itsaky.androidide.models.OpenedFilesCache
import com.itsaky.androidide.models.Position

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fryanpan low — unused import.

Position appears only on this import line; the identifier is never used in the file. Same standard:no-unused-imports / Spotless-ratchet issue as the two EventBus imports added to ProjectHandlerActivity.kt.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: still open at head - Position occurs exactly once in the file, on this import line.

Worth knowing why the tooling misses it: the file contains 47 occurrences of the substring Position (tabPosition, fromPosition, ensurePositionVisible, ...), and ktlint's unused-import detection is substring-based, so the identifier reads as used. spotlessApply will not remove this one for you.

@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 6254159 to 7e90fff Compare September 1, 2026 02:06
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 7e90fff to 1f82366 Compare September 1, 2026 06:59
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 1f82366 to 5f92da7 Compare September 1, 2026 07:31

@itsaky-adfa itsaky-adfa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 - verified at 5f92da7

Re-reviewed after the force-push. All seven findings from the 2026-08-31 round are still open. Each is answered in its own thread rather than reopened here. Two moved: the status-bar re-announce goes up to IMPORTANT (it fires on the primary loop, not only on activity recreation), and the clobber-dialog re-arm goes down to MINOR (the rotation scenario does not reproduce - EditorActivityKt declares configChanges="orientation|screenSize|screenLayout|smallestScreenSize|fontScale").

New this round: 3 IMPORTANT, 2 MINOR, 2 NITPICK, all inline.

Findings without a diff anchor

MINOR: no font-scale verification is stated, and this diff changes text surfaces. REVIEW.md section 8 requires new or changed screens to be verified at font scale 1.0 and 2.0 with the result in the PR, and is explicit that "no visual change" is a valid one-line opt-out but silence is not. This PR adds a toolbar button, a long-press dropdown and six new status-bar strings; the description covers automated tests, a manual-QA walk and benchmarks, but says nothing about font scale, and quickbuild/docs/manual-qa.md has no font/scale/TalkBack step either. That is precisely the check that would have caught the truncation finding on layout_editor_build_status.xml.

Checked and found sound

Recording these so the coverage claim is checkable and not just the failures:

  • Gradle tuner - metaspace 192 -> 384 on the Low/Balanced tiers, tiered idle timeouts, five new tests including the tier-inversion guard. -Dorg.gradle.daemon.idletimeout is correctly documented as taking effect only for daemons started after the change, since idletimeout is not part of Gradle's daemon compatibility spec.
  • Flag-off safety - the new slot-busy guard in AbstractCancellableRunAction sits after the cancel branch, so with experiments off a stop tap still cancels. No regression on the standard Run path.
  • Leaks - QuickBuildOutputNarrator's scope is Main.immediate, so the unlocked pending/sink really are confined to one thread; the bind/unbind identity check handles new-onCreate-before-old-onDestroy; the queue is bounded at 200; unbind runs on onDestroy, so the process-wide singleton holds no activity. internalBuildObserver is scoped to the activity as LifecycleOwner.
  • Threading - isAndroidResource runs off the main thread; GenerateSourcesDeferral's lock discipline, bounded refusal budget and both Koin-up/Koin-down paths hold.
  • Install path - InstallationResultHandler returning null on suppress only affects doLaunchApp, its single consumer.

Three things I nearly flagged and disproved: msg_build_slot_busy does exist (base branch, strings.xml:1190); the new debug manifest's 4-space indent matches the main manifest (383 space-indented lines, 0 tabs); and "Code on the Go" in the new crash string matches app_name and 43 other uses.

Verdict

Requesting changes on the two IMPORTANT findings that reach users - the status-bar re-announce and the truncated remedy - plus the ungated dropdown and the unswept third generateSources() call site. Per CLAUDE.md's Jira rule ("a review comes back with no outstanding critical, high, or medium findings -> QA"), ADFA-4128 should stay in Code review until those clear. The MINOR and NITPICK items are not blockers.

The description issue on QuickBuildBenchAutostart.kt is worth settling before QA reads it, since it describes the benchmark methodology behind the 5x claim.

// Through the registry, same as Standard Run below, so the menu entry
// and the toolbar tap share one code path (incl. the analytics event).
val quickBuild = registry.findAction(EDITOR_TOOLBAR, QuickBuildAction.ID)
if (quickBuild != null) registry.executeAction(quickBuild, data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: the long-press dropdown runs the Quick Build action ungated, so the item labelled "Quick Build" can cancel a running build or start one that is already refused.

DefaultActionsRegistry.executeAction performs no enabled check, and the toolbar supplies its own at line 564 (onClick = { if (action.enabled) ... }). This call site has neither, and menu_quick_build.xml hardcodes android:title="@string/quick_build_action_label", so the item never reflects what prepare() computed.

Two concrete failures. While a Quick Build is running: long-press, tap "Quick Build", and execAction's first branch (currentTone() == BUILDING) calls onCancelRequested() - the build the user is waiting on is cancelled by an item that says it starts one. While a standard Gradle build holds the slot: prepare() set enabled = false and the label to "Standard build in progress", but the item still runs, saving every open buffer before failing with "Another build is running" - which is exactly what the toolbar's gate exists to avoid.

QuickBuildAction.prepare()'s KDoc asserts the label "is what the long-press dropdown and the overflow menu read"; that is not true of this menu. Reading quickBuild.label into the item and gating on quickBuild.enabled fixes both.

// Routed through the deferral: immediate with no Quick Build session, parked and
// coalesced until the session pipeline settles with one (see GenerateSourcesDeferral).
if (processResources && result.resourceXmlSaved) {
GenerateSourcesDeferral.notifyResourceSaved()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: the generateSources narrowing missed its third call site - the defect is at EditorHandlerActivity.kt:1133, outside this diff.

saveFileResult (the plugin save path, reached from EditorProviderImpl.kt:281) still does if (result.xmlSaved) { ProjectManagerImpl.getInstance().generateSources() }. Two consequences: a plugin saving AndroidManifest.xml or any non-resource XML still triggers a full Gradle build, so the save-latency win this PR advertises does not apply on that path; and with a live Quick Build session that direct call bypasses GenerateSourcesDeferral entirely, taking the single Gradle slot mid-pipeline - the exact contention the deferral exists to prevent.

The comment above it ("The same follow-ups the UI save paths run (see [saveAll] and SaveFileAction.postExec)") is now false on both counts, which is what will mislead the next reader.

The fix is one line: saveEditorInternal is shared by both paths and already populates resourceXmlSaved via accumulateSaveFlags, so line 1133 becomes if (result.resourceXmlSaved) { GenerateSourcesDeferral.notifyResourceSaved() }.

*/
object QuickBuildBenchAutostart {
const val MODE_QUICK_BUILD = "quickbuild"
const val MODE_STANDARD = "standard"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: the PR description documents MODE_STANDARD_E2E, which does not exist at this tip - and the code does the opposite of what the description claims.

git grep STANDARD_E2E over the head tree returns nothing. This object declares only MODE_QUICK_BUILD and MODE_STANDARD, and QuickBuildBenchActivity rejects any other value. The description names it four times: in "What to review" as a line-by-line item, as a node in the mermaid diagram, and as "MODE_STANDARD_E2E drove the standard arm through install and launch".

Meanwhile ProjectHandlerActivity.onBuildStateChanged computes suppressInstall from QuickBuildBenchHooks.standardBuildEnded(...) and skips installApk(state) for an autostarted standard build, commented "the measurement ends at the build result". So in this PR the standard arm stops at the build result and its install is suppressed - not driven through install and launch.

This matters past documentation: the headline "about a 5x median speedup" depends on where the standard arm's measurement stops. Either the harness measures install and launch externally, in which case the description should say so rather than point at this file, or the two arms are not measured like for like. Worth settling before QA works from it.

apk: File,
launchInDebugMode: Boolean = false,
debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER,
requestDowngrade: Boolean = false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: requestDowngrade is never passed true by anything, so everything it gates is unreachable.

Every occurrence on the head branch is a declaration with a false default or a pass-through: installApk -> installUsingSession -> createSessionParams, plus ApkInstallationViewModel.installApk's own unused default. ProjectHandlerActivity.doInstallApk and the Koin launchInstall in QuickBuildModule both omit it.

So the reflective setRequestDowngrade call and the MIUI "cannot request a downgrade" warning can never execute, and the same-app-id Quick Build restore this KDoc names as the reason for the parameter will still be rejected by the OS with a version-downgrade failure. Either wire the restore path to pass it, or drop the parameter - as it stands the next reader will assume downgrades are handled.

// Arm the editor's one-shot autostart BEFORE opening, so the tap fires as soon as
// this project initializes (see ProjectHandlerActivity).
QuickBuildBenchAutostart.pendingMode = mode
QuickBuildBenchAutostart.pendingProjectPath = project.path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: a bench re-open of a different project arms the autostart against a stale project model. Debug plus bench-flag only, so no user or release build is affected.

With project A open and a bench intent for project B: this sets ProjectManagerImpl.projectPath = B and starts the editor SINGLE_TOP. ProjectHandlerActivity.onNewIntent neither calls setIntent nor re-initializes - it claims and fires. claimAutostart() canonicalises IProjectManager.projectDirPath, now B, so the latch matches and onQuickBuildTapped() runs immediately while workspace still holds A's module model.

I verified the control flow but not the runtime outcome, so treat the consequence as likely rather than measured: a proxy-app build resolving A's module path against B's directory would make that benchmark run silently meaningless rather than fail loudly. Comparing the intent's path against the initialized project, not just the mutated projectPath, would close it.

// Additive relaunch fields: whether the reinstalled app came back running, and
// rebuild start -> runtime reconnect. toRunningMillis rides only on a relaunch
// that reconnected - absent, never a measured zero.
put("relaunchOk", relaunchOk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: the PR description names the wrong field here. It says "BenchEventsFile.kt - a failed relaunch omits relaunchOk rather than recording zero", but relaunchOk is written unconditionally on this line; it is toRunningMillis on the next line that is conditionally omitted. The code and its comment are both right - only the description is off. (BenchEventsFile.kt carries no relaunch field at all.)

~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<?xml version="1.0" encoding="utf-8"?><!-- ~ This file is part of AndroidIDE. ~ ~ AndroidIDE is free software: you can redistribute

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: the GPL header was reflowed into one run-on line by the Eclipse WTP formatter, as ratchet collateral for a one-attribute change (android:ellipsize="end"). Still valid XML and legally intact, but the header is now unreadable and it accounts for most of this file's diff. Restoring the line breaks, or wrapping the header so the formatter leaves it alone, would keep the real change visible.

fryanpan and others added 14 commits September 2, 2026 18:37
…adds the debug-only benchmark harness

The app wiring and the bench surface land together because they are mutually
dependent: :app's ProjectHandlerActivity and QuickBuildModule call into
QuickBuildBenchHooks, and QuickBuildBenchHooks returns AutostartBuild and resolves
EnvironmentQuickBuildPaths. Neither ordering of a two-PR split compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
…ent, test gaps

Important 1 (daemon idle-timeout/Metaspace tuner un-gated): kept un-gated by
design — the 384m Metaspace floor fixes real OOM-killed builds and the tiered
idle timeouts keep low-RAM devices from losing the IDE to lmkd; GradleBuildTuner
now states this in its KDoc. Ships flag-off; needs Bryan sign-off in the PR body.

Important 2 (generateSources narrowing un-gated): judged a genuine all-users
improvement, not QB-specific — the old code ran a Gradle generateSources after
EVERY save-all (and after any XML save in SaveFileAction), a per-save build tax;
flag-off the deferral degenerates to the same immediate call, so the narrowing
is the only behavior change. Known trade (manifest-only edits leave generated
Manifest/R intermediates stale until the next resource save or build) now
stated at both call sites. Ships flag-off; needs sign-off in the PR body.

Important 3 (install dropped on rotation, flag on): installApk's async path now
re-arms AwaitingInstall (BuildViewModel.reArmInstall, fires only from Idle)
from the coroutine's drop path, so a configuration change during the APK-manifest
parse makes the recreated activity's collector retry the install instead of
silently losing a successful build. Covered by BuildViewModelInstallReArmTest.

Test gap (zip-slip guard): extraction loop extracted to
QuickBuildArtifactStager.extractDaemonZip(InputStream, File); the guard is
watched going red by QuickBuildArtifactStagerTest (a ../ entry throws and
nothing lands outside the daemon dir).

Test gap (InstallationEventFlow mapping): InstallationEventFlowTest pins the
PackageInstaller status mapping, including the ABORTED-vs-FAILURE branch order
and the no-extras / no-status paths.

Test gap (service-side output capture): suppress/capture/drain routing
extracted from GradleBuildService.logOutput into InternalBuildOutputCapture;
bounded tail, drain-clears, throwing progress listener and editor-listener
routing pinned by InternalBuildOutputCaptureTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
…tiple

The Gradle daemon idle-timeout comment quoted a speedup multiple, which put a
benchmark figure into shipping production code. The reason the timeout is
generous is structural - a warm daemon skips the cold start, which dominates a
short rebuild - so the comment now says that instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
- F1723-1 put QuickBuildPipelineTest into the suite that actually runs
- F1723-4 guard the deferred prebuild fire() against a throw

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
The script has had one commit and predates several behaviour changes, so a
walker following it literally hits steps that cannot reach their stated end
state. None of these are product defects - the walk found no product failure.

- T7 criteria 4/5 described the pre-b6cddf035 world where rebaseline left the
  app un-relaunched and a tap was needed. Rebaseline relaunches now.
- T7b step 2 and T11 step 3 end at a modal OS install prompt that never times
  out; "do not tap anything" could not reach the end state.
- T1 gains a FAB baseline tap. Five later tests assert through the FAB, so a
  dead FAB failed them all with no way to tell when it broke.
- T1 gains a note that project creation already ran a setup build, so the first
  tap measures warm provisioning, not cold.
- T14's "no reinstall unless the bytes changed" inverted the design: the
  generation stamp lives in the APK, so a restart always mints new bytes.
- T20 names service-app; only 4 of 30 corpus apps declare a Service.
- T21 drops "wrap and push sora-editor-full first" - already wrapped, with all
  288 source files.

Adds a "Traps that make the product look broken" section for the four method
errors that produced wrong findings: tapping the geometric centre of a view
that extends under a system bar, Find-in-file being a regex search, relaunching
CoGo via monkey when it declares two LAUNCHER activities, and selecting a
wrapped corpus copy by mtime when the newest is pinned to AGP 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
Six places where a Quick Build doc contradicted the code it describes. Each was
re-verified against the source rather than taken from the review comment.

- debugging.md: the deploy round-trip row said the 15 s bound covers "one AIDL
  onPayload call". IQuickBuildTarget is a oneway interface, so that call returns
  immediately; DeployChannel wraps the call plus the wait for a
  generation-matched report, which is what its own KDoc already said.
- why-not-android-jar.md: listed native libs as hot-loadable. A .so under
  jniLibs forces a Gradle fallback (ChangeClassifier). Loadable at runtime and
  changeable via live reload are different properties.
- reliability-gaps.md: "five user-facing defects" against three fixed and four
  open. Seven were surfaced; the fixed three are relink-stuck, #88 and #90. Also
  states why Blocks v1? reads TBD - the decision at the top is a proposal, and
  the cells become "No" when it is confirmed.
- low-spec-devices.md: stated an inferred mechanism (SerialGC thrashing in a
  small heap) as the confirmed cause of the 1.9 GB failure. The outcome is
  measured; the mechanism is not, and the uncapped run that would confirm it is
  still unmeasured. Retitled to what was actually observed.
- concurrency.md: the tap-races-its-own-save section read as current behaviour.
  It describes the pre-2026-08-13 design that the redesign below it replaced.
- perf-roadmap.md: incomplete sentence.

Not applied: CodeRabbit's finding that manual-qa.md's screenrecord
--time-limit 1740 is invalid because AOSP caps at 180 s. False on our hardware -
recordings of 1774 s, 2432 s, 2592 s, 2842 s and 3534 s have all completed on
the A56, and the surrounding comment already documents the real 30-minute cap
that 1740 sits under. Applying it would break working recordings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
None of these is a Quick Build failure - the walk passed every test. They are
places where the product is correct and unhelpful.

A2 - the bolt read identically to a screen reader in READY and ERROR. Every tone
has its own icon shape, so a sighted user can tell them apart; collapsing them
all to "Quick Build" hid that distinction from exactly the user who cannot see
the icon. ERROR, SLOW and RECONNECTING now announce their state. BUILDING and
the standard-build-blocked case already did.

A3 - after an undeliverable build the bar read "built, but could not be
delivered - see Build Output" on every poll, while the sentence naming the fix
("Your app is not running. Tap Quick Build to start it with your changes.") was
only in Build Output. The bar now names the tap when that is the whole problem.
Carried as a typed flag rather than matched on the message text, the same way
proxyAppNotConnected already is, and kept separate from it because they mean
opposite things: appNotRunning is "nobody opened it", proxyAppNotConnected is
"we launched it and it still did not arrive".

A4 - an orphaned proxy app reported CoGo's expected connect() rejection at W on
every attempt of the rebind backoff loop, 14 times in one restart window. The
behaviour is right (it continues standalone); repeating an expected rejection at
W buries the entries around it. Reported once per streak now, cleared by a
successful connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
The page proposed that #87, #89, #91 and the relink-crash gap go to v1.1,
then left the table's "Blocks v1?" column reading TBD on all four rows. A
proposal in a title and a TBD in a table say different things to a reader,
and CodeRabbit flagged the pair as an internal inconsistency.

Decision confirmed 2026-08-25: none of the four block v1. The four cells now
read "No - v1.1", the title states the answer rather than asking it, and the
prose no longer describes itself as awaiting confirmation.

No change to any gap's evidence, root cause, or fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
…ing branch

F1713-3 (docs/concurrency.md): the thesis said every expensive thing runs
in another process while the table directly below it put the mtime poll
and the install call on Dispatchers.IO inside CoGo. Name the exception.

F1713-8 (docs/manual-qa.md): files are not killed, processes are - and a
teammate follows this runbook literally while holding a half-recorded QA
session. Say screenrecord.

Both patch text that exists only in the four trailing commits, so they
could not ship until those commits had a home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC
…ded Cancel

Both found by running the CodeRabbit CLI over this branch ourselves, since the
pull request's 119 files exceed the bot's 100-file cap.

- installApk consumed the tap-time clobber answer before entering the coroutine,
  so the answer left the ViewModel whether or not the install went on to
  dispatch. A rotation during the manifest parse cancels the coroutine, the
  finally block re-arms the install, and the retry then ran with no tap-time
  answer - asking the user to confirm the same overwrite a second time. The
  method's own KDoc says the re-check is silent unless the answer moved, which
  is precisely what this broke. The consume now sits inside the coroutine after
  the destroyed check, on the path that actually dispatches; there is no
  suspension point between it and the dispatch.

  Verified with a throwaway harness rather than a committed test: it drove the
  real BuildViewModel and installTimeClobberConfirmation through both orderings
  with a real cancellation, showed the old ordering re-asking and the new one
  silent, and was watched going red under a mutated expectation. It is not
  committed, because ProjectHandlerActivity is abstract and untested by any of
  the 64 JVM test files in app/src/test, so a committed version would model the
  ordering rather than read it and would stay green if the line moved back. The
  ordering is guarded by review only.

- QuickBuildScreen.declineClobberConfirm matched a hard-coded English "cancel"
  while its sibling acceptClobberConfirm resolved its label from resources, so
  the decline path alone broke on a non-English device. Both confirm paths reach
  one builder, which sets android.R.string.cancel, so the framework string is
  the right resource. Compiles; runtime behaviour on a non-English locale is
  unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC
…e phone banner

Bryan pinned the wording on 2026-08-28 for the phone banner; the CoGo-side
notice for the same event still said "Your app crashed... Fix the crash and
save", which sends the user to fix code that was fine. The state is only ever
set from failReload, so the reload machinery failed, never their code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC
…ring

qb-08's review fix added QuickBuildMessage.ProvisioningFailedUnexpectedly,
the named case for a provisioning throw with no message of its own. The
exhaustive when in QuickBuildMessages.resolve had no arm for it, so the
restacked qb-11 would not compile.

Maps it to quick_build_provisioning_failed_unexpectedly, worded like the
neighbouring quick_build_setup_failed, and pins the mapping in
QuickBuildMessagesTest alongside the other valueless cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci
Entry #89 (failed daemon respawn strands the session) was written against
the prototype and went stale: the missing QuickBuildTapped arm in
reduceDegraded landed with qb-08's review fixes, a failed respawn now
dispatches DaemonRestartFailed and surfaces a message, and qb-07's
trim-memory redesign no longer bumps the daemon epoch. Move #89 from the
open list to fixed-on-this-branch, update the counts and decision line,
and state what remains: no device repro on either side of the fix, so
the recovery arms are host-tested only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwVV7PzMYinSiq6FwC83Vw
Akash's 2 September round on the IDE-side wiring, plus the seven findings from
08-31 that were still open at head.

- The long-press dropdown's Quick Build row presents the same state as the button
  it hangs off: the toolbar's own prepare() already decided whether a build can
  start and what a tap does, and the row ignored both - so it offered "Quick
  Build" while the button was a stop button, and a tap there cancelled the build
  the user was waiting on.
  #1723 (comment)
- The plugin save path gates generateSources on resourceXmlSaved and routes it
  through GenerateSourcesDeferral, the same as the two UI save paths. It was the
  sibling the narrowing missed, so a plugin's save still ran a full
  generateSources on every xml and raced a live session's build.
  #1723 (comment)
- MODE_STANDARD says what it measures: the build only, because the install dialog
  an unattended run cannot answer is suppressed. Quick Build's arm measures build,
  deploy and reload, so the two are not like for like from in-app numbers alone.
  #1723 (comment)
- A landed build is announced once, not on every re-subscribe. The status is a
  StateFlow and the editor re-collects on every return to it, so the first
  emission was re-announcing "reloaded in 2.0s" over whatever the bar held,
  minutes after the build. Pinned by a test that fails without the guard.
  #1723 (comment)
- The status text takes three lines instead of one, and the app-not-running line
  is short enough to read: at 92 characters it was cut mid-sentence on a 360dp
  phone at the DEFAULT font scale, and the half that was cut was the remedy.
  #1723 (comment)
- The install's clobber dialog is awaited, so the coroutine is alive while it is
  up and an activity destroyed under it re-arms AwaitingInstall. dispatched moves
  after the decision. Moving it into the dialog callbacks as suggested does not
  work: they run long after the coroutine body returns, so the re-arm fires
  mid-dialog and loops - the re-armed AwaitingInstall shows a second dialog
  behind the first, and a decline shows a third.
  #1723 (comment)
- Only Needed tears the session down. NeededForUnknownAppId means the APK's own
  package did not parse, and a transient read cost the user their warm session.
  #1723 (comment)
- A Quick Build tap with no activity refuses rather than building: it needs one
  to flush the editor buffers and to ask about a clobber, and no caller reaches
  it today.
  #1723 (comment)
- Three unused imports, deleted by hand - ktlint's detection is substring-based,
  which is why a green spotlessCheck did not catch them.
  #1723 (comment)
  #1723 (comment)
- manual-qa gains a font-scale block (T22): the 1.0/2.0 pass over the toolbar,
  the dropdown, every actionable status line and the clobber dialog. It is the
  check that would have caught the truncation above.

Two repairs the round did not ask for, found running the suite: :app's unit
tests did not compile at head (SessionRestartAndReprovisionRequested is a data
class and was referenced without its parentheses), and once they did,
FeatureFlagsTest failed five ways - it reflects on a `by lazy` property's field,
which is named downloadsDir$delegate and holds the Lazy. Both are one-liners and
:app:testV8DebugUnitTest is green.

The clobber gate follows the PR below it: QuickBuildClobberCheck's two reads
became suspend there, so quickBuildClobberConfirmation takes a suspend probe and
the two ensure*ClobberConfirmed gates own the coroutine rather than pushing it
onto their click-handler callers. And the unused-import sweep keeps
models.Position: stage's deep-link work landed a use for it while this branch was
out.

Not fixed here: requestDowngrade's unwired parameter, the bench re-open against a
stale project model, and the run-on GPL header - all deferred with reasons in
this round's replies. The PR description's own MODE_STANDARD_E2E claim and the
BenchQuickBuildMetricsSink field name are description work, drafted there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A
@fryanpan
fryanpan force-pushed the feature/ADFA-4128-qb-11-app branch from 5f92da7 to 8f79f47 Compare September 3, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants