glm said this flicker requires a full workflow, I switched to opus and fixed it directly—it first pointed out what would break


After the entire EPG section was completed and passed on-device testing, I sat in front of the TV to try it out myself. The overall experience was good, except for one thing: the program list and program content would periodically black out completely before reappearing.

As a developer, I knew this was a background update—the EPG periodically re-fetches the program schedule. But as a viewer, it felt very annoying: I wasn’t interacting with anything, yet the screen flickered on its own. It happened about every 20 seconds, and logcat showed it too, printing an EPG_PROGRAMS(_EMPTY) entry every 20 seconds, which was that background reload running. Focus placement and everything else were correct; it was purely a pointless, full-black flicker.

So I mentioned this to glm.

glm’s Judgment: Found the issue, but the impact is broad and requires a full workflow

Upon checking, glm said it found the issue—but the impact was broad, requiring a full workflow to fix.

This project has a multi-stage review workflow: any change other than pure UI modifications goes through a complete process (brainstorm → spec → plan → plan-review, including an external reviewer → implementation → dual reviewers). The reason it exists has been repeatedly validated by the project itself—the most common disease of such reactive pipelines is “failing to trace everything dependent on a certain state before taking action.” The workflow is designed to force this exact tracing.

At the time, I thought: Could it really be that complicated? It’s just a flicker. So I asked glm, “Can you just fix it directly?” glm still reminded me once more—it still recommended going through the full workflow.

That “reminded me once more” was crucial. If it had just said it casually, I might have simply pushed back and told it to make the change; but the fact that it insisted once more after I stated my preference meant it genuinely believed the issue was not straightforward. Since it made this claim, I decided to switch to opus—not because I felt glm was incapable, but because I wanted a more powerful brain to see the full picture before deciding.

I had a default assumption when switching: if opus also recommended running the full workflow after checking, then I would simply resign myself to agreeing. If two different models say an issue isn’t straightforward, then it truly isn’t, and I would accept that cost. In other words, I wasn’t looking for a model that would agree to “fix it directly”—I was using opus as a second opinion on glm’s “broad impact” claim, and I was prepared: if the second opinion also sided with glm, I would follow glm’s path.

Fortunately, after checking, opus was confident it could be fixed directly.

opus’s Judgment: Can fix directly, but it will freeze the time bar

opus first read glm’s analysis report, then checked it again itself. Its conclusion differed from glm’s: this issue could be fixed directly, but fixing it would cause another problem—the time bar.

This was exactly what glm’s “broad impact” meant in concrete terms. opus gave it a name: the progress bar (time bar) in the preview pane would stop. Then, having completed the analysis, opus believed the situation was fully understood and could be fixed directly without going through the full workflow.

So I let it fix the issue directly.

Root Cause: The new version ties loading to “what triggered it,” while the old version tied it to “whether data changed”

The cause of the flicker itself lay in the new EPG program-querying flow (…/presentation/epg/EpgViewModel.kt). There was a background reload chain:

  • reloadTriggerFlow (:137-142): while(isActive){ emit(now); delay(20_000) }, emitting a tick every 20 seconds.
  • combine(selectedDateIndexFlow, selectedChannelFlow, reloadTriggerFlow) (:145) combined three signals into one—which day was selected, which channel was selected, and this 20-second tick.
  • .onEach { it.copy(isProgramsLoading = true, programs = emptyList()) } (:149-151), after combine and before debounce, unconditionally set loading and cleared programs for every trigger (including the reload tick).
  • .debounce(300) (:152) → .flatMapLatest{ 查節目 } (:153) → .collect{ 設 programs、loading=false } (:205).

The result was: every reload tick went through onEach → loading=true plus clearing → a full-black flicker → displaying again after the query finished. This happened regardless of whether the data actually changed.

The old version was not like this. The old version was written in Java (…/common/ui/fragments/ProgramListFragment.java), and after querying the programs back, loadPrograms would first compare the new with the old:

if (!forceUpdate && programData!=null && temp.length==programData.length) {
    for (int i=0;i<temp.length;i++)
        if (!temp[i].getProgramUri().equals(programData[i].getProgramUri())) { isNew=true; break; }
} else { isNew = true; forceUpdate=false; }

After comparing, it only swapped data, showed the loading mask, and redrew if the data had changed (isNew==true); if the data had not changed (isNew==false), it would simply return—neither clearing nor showing the mask, but only updating the progress in the preview pane.

AspectOld VersionNew Version
loading Trigger TimingAfter query, masks only if confirmed data is differentBefore query, unconditional (onEach)
reload, data identicalreturn, no mask, only updates progressStill loading full-black + clearing ❌ (The current issue)
reload, data differentmask + redrawloading + redraw (Correct result)
Channel change (selected changed)Data guaranteed different → mask + redrawloading + redraw (Correct result)

The key difference lay in a single sentence: the old version’s loading was driven by “whether data changed,” while the new version’s was driven by “what triggered it.” When the old version reloaded and returned identical data, it did not disturb the UI; the new version, regardless of what it returned, would blindly flicker black the moment the reload tick arrived.

That “Broad Impact”: The flicker was the only thing keeping the screen alive

If it were just “changing onEach to be conditional,” this issue would have been simple, and glm would not have claimed a broad impact. The troublesome part was the side opus pointed out.

There were two elements on the screen that changed over time: the clock in the top right corner and the progress bar in the preview pane. At EpgScreen.kt:124 and EpgPreviewPane.kt:76,83, they read System.currentTimeMillis() directly. The problem was—they did not have their own time source, meaning they parasitized the screen redraws caused by the loading toggle every 20 seconds. The loading toggled once, the screen redrew once, and they subsequently read the new time once.

Therefore, that flicker I wanted to remove was actually the only source of redraws on the screen at that time. Simply removing it would cause the clock to freeze at the exact moment the EPG was opened, and the progress bar would stop. This was precisely why the old version explicitly called updateProgramInfo(selectProgram) one more time even when “data didn’t change”—the old version knew that even if the data wasn’t swapped, the progress still had to be refreshed.

So the fix was two things, not one. Acting upon only seeing the “flicker = unconditional loading” layer would create a new regression: freezing the time bar. glm’s “broad impact” was correct; this issue truly was broad.

Solution: Give the clock its own time source, then converge the loading

opus modified four files on the spot, all under presentation/epg/:

FileModification
EpgUiState.ktAdded nowMs: Long (default System.currentTimeMillis())
EpgViewModel.kttimeTicker writes to nowMs every 20 seconds (day-change logic preserved as is)
EpgViewModel.ktonEach changed to trigger loading=true + clearing only when selection key (dateIdx+sid) changes; reload tick queries silently
EpgScreen.ktClock changed to read state.nowMs
EpgPreviewPane.ktAdded nowMs parameter, progress bar changed to read it

Two things: it gave the clock and the progress bar their own time source nowMs, no longer parasitizing the flicker’s redraws; then it converged onEach to only trigger loading during an actual channel change or day change, leaving the reload tick to query silently.

One detail is worth mentioning: the old version’s logic of “comparing program identities after querying and skipping if identical” actually did not need to be written in the new version. Because EpgUiState, EpgProgramRow, and EpgProgram were all data classes, state.copy(...) would produce an equal object when the data was identical, and StateFlow inherently deduplicates and does not emit—which was equivalent to the old version’s return. The comparison loop that the old version had to write by hand was obtained for free in the new version through data class equality plus StateFlow deduplication.

During verification, the progress bar required the most caution. It only advanced about 12.7px every 20 seconds, which was completely imperceptible to the naked eye. And after fixing the old flicker, the screen also “looked static”—a frozen time bar and a normally operating one were visually indistinguishable. Therefore, this item could not rely on visual inspection; I had to measure the orange pixels using the framebuffer: over 161 seconds, the progress bar advanced 102px (158→260 / 576px), which reverse-calculated to a program length of about 909 seconds; during the same time, the count for that channel in logcat remained unchanged, proving that the same program was advancing and nobody had touched the channel selection. The clock was still ticking, changing channels or days still triggered a full-black loading, and the silent background requeries every 20 seconds were still running. The flicker was gone, but everything that should move was still moving.

Trade-offs: The workflow is a crutch for tracing coupling

This whole affair was less about “opus being stronger than glm” and more about the two models each achieving a different step. And I chose to fix it directly to avoid escalating the situation—not to save money. Quite the opposite: opus was much more expensive than glm, roughly 7 times the price on paper, and factoring in quota limits it was nearly 20 times the cost; fixing it directly was actually the expensive route.

glm sensed this issue wasn’t straightforward—“broad impact”—but it did not trace that “breadth” completely through its mind and ground it into a concrete thing. Thus, its prescription was to reach for the workflow that would force tracing through the coupled surface area. This was a safe, correct choice, and hindsight proved its intuition right: the time bar trap really did exist.

opus mentally performed exactly what the workflow would enforce—tracing through the entire coupled surface area, and it grounded glm’s intuition of “broad impact” into the concrete “will freeze the time bar.” Because it concretely stated that second-order consequence, I believed it truly had a grasp on it rather than having missed something.

The complete workflow exists precisely to enforce this easily omitted task of “tracing the entire coupled surface area before taking action.” Frankly speaking, it is a crutch for coupling tracing. If glm could not trace it completely itself, it used the crutch; since opus traced it completely, it could forgo the crutch. There is nothing wrong with the crutch itself, and holding it still gets you to the finish line—I believe that if I had let glm run the full workflow, this issue would have been resolved properly all the same.

But for me, this was a trade-off, and not a money-saving one. For a nearly fully verified feature with only one annoying little flicker remaining, I really did not want to spin up a full workflow for it—escalating a minor issue into a formal task with a spec, a plan, and dual reviewers. glm’s path was actually cheaper, but the cost was escalating the situation; I would rather spend 20 times the money on opus in exchange for a result that could grasp the full picture, fix it on the spot, and keep the matter contained. The reason I dared to take this path was that opus completed what the workflow would enforce—it wasn’t me being lazy, nor was I picking the cheap route. It still wasn’t a case of me “switching until I found a model that agreed with me”: I used opus as a second opinion for glm, prepared to comply if opus also said to run the workflow; it ultimately got fixed directly because opus independently overturned glm’s conservatism with a concrete reason (the time bar would freeze), not because I wanted that answer.

How to Judge “True Grasp”

This issue provided me with a more concrete criterion than “whether to initiate a workflow.”

A model saying “this issue has a broad impact, I recommend running the full workflow” versus it saying “this issue can be fixed directly, but it will affect X” represents two completely different levels of grasp—the former is intuiting a risk but being unable to articulate it, while the latter is a risk already concretized into a name. Deciding whether to skip the workflow depends on whether the latter appears: did it state the secondary consequence as a concrete thing? opus stated “the time bar will freeze.”

Conversely, if a model just says “no problem, change it directly” without being able to state anything that will be secondarily affected, that doesn’t necessarily mean it didn’t look—it’s more likely its inspection stopped at the bug itself and didn’t extend to where the modification might ripple. This is not a hypothetical danger: in my experience, gemini is particularly prone to doing exactly this; a simple “okay, yes, no problem,” and only after the change do you realize a bunch of problems were introduced. But this isn’t because it didn’t look—it does touch the actual state, but it only touches the piece I pointed to: if I say where the bug is, it fixes that bug and confirms the bug is gone; as for whether this change will ripple elsewhere, it generally does not proactively probe.

Like opus, it will say “it can be fixed directly,” but the difference is that opus’s inspection scope extends beyond the bug—it attached that concrete “will freeze the time bar.” For the exact same “no problem,” one’s inspection stops at the bug itself, while the other’s inspection extends to secondary impacts, yet they sound exactly the same.

That is why I say whether it stated that concrete thing is the only distinguishable clue. glm grazed the edge but couldn’t state the full picture, so it retreated to the insurance of the workflow; gemini only touched what was in front of it and claimed everything was fine; opus touched everything completely and could articulate what would break—among these three, only the last one can make me feel safe skipping the workflow.