Skip to content

Commit 521dbf8

Browse files
committed
fix(perf): bulletproof Linux gate via vite-define + kill tab-status-grid wave
v2.5.0 gated the SMIL steam morph on `navigator.userAgent.includes('linux')`, but live testing on Ubuntu 26.04 + KDE Plasma Wayland + WebKit 2.52 still showed coffee-cli + WebKitWebProcess pegging cores — the UA gate didn't actually fire. WebKit2GTK's UA string is configurable per-distro / per-Tauri- version and is not a load-bearing signal. Switch to a build-time constant `__IS_LINUX__` injected by vite via `define`, resolved to `process.platform === 'linux'` at compile time. Each platform's installer is built on its own CI runner (ubuntu-22.04 / windows-latest / macos-13), so the constant is locked into the binary at build time — no runtime detection, no UA dependency, zero misfire risk. Also gate the tab-status-grid (the "dynamic island" status indicator on claude/codex/opencode tabs): 9 dots × 1.2s opacity-loop infinites, each with a `box-shadow: 0 0 3px` halo. WebKit2GTK + Cairo doesn't promote small inline elements to compositor layers, so every opacity tick re-rasters the box-shadow blur on CPU; with N tabs that's 9N indefinite repaints. Linux now gets a single static 5px colored dot — same idle/working/waiting color signal (green/orange/blue), zero animation cost. Win/Mac unaffected — WebView2 (Chromium) and WKWebView (Core Animation) both auto-promote opacity-animated layers and run them on the GPU compositor thread, so the wave/snake/ripple stay free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b617c44 commit 521dbf8

8 files changed

Lines changed: 52 additions & 7 deletions

File tree

src-ui/src/components/center/CenterPanel.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@
199199
.status-waiting .tab-status-dot:nth-child(8) { animation-delay: 0.15s; }
200200
.status-waiting .tab-status-dot:nth-child(9) { animation-delay: 0.3s; }
201201

202+
/* Linux variant — single static dot, no animation, no box-shadow blur.
203+
The 9-dot wave/snake/ripple loops + box-shadow halos peg WebKit2GTK +
204+
Cairo (no compositor-thread opacity for non-promoted layers; box-shadow
205+
blur re-rasters on CPU each frame). Sized up to 5px so a lone dot reads
206+
as deliberate. Color still conveys idle/working/waiting via the status-
207+
class background-color rules above; we override box-shadow here to kill
208+
the halo. Specificity (0,2,0) matches the status-* rules and source
209+
order wins. */
210+
.tab-status-grid--static {
211+
grid-template-columns: 5px;
212+
grid-template-rows: 5px;
213+
}
214+
.tab-status-grid--static .tab-status-dot {
215+
width: 5px;
216+
height: 5px;
217+
animation: none;
218+
box-shadow: none;
219+
}
220+
202221
/* ─── Terminal Wrapper ─── */
203222

204223
.terminal-wrapper {

src-ui/src/components/center/CenterPanel.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ const SvgInstaller = () => (
169169
brand icon and together they pegged WebKit + coffee-cli at
170170
~1.2 cores idle on Wayland (verified live via SSH /proc
171171
sampling). Static `d` on Linux = no steam drift, full glyph. */}
172-
{!navigator.userAgent.toLowerCase().includes('linux') && (
172+
{!__IS_LINUX__ && (
173173
<animate
174174
attributeName="d"
175175
dur="3s"
@@ -1141,8 +1141,17 @@ export function CenterPanel() {
11411141
session.tool === 'vibeid' || session.tool === 'insights_prerun'
11421142
? 'working'
11431143
: (session.agentStatus === 'wait_input' ? 'waiting' : session.agentStatus ?? 'idle')
1144-
}`}>
1145-
{Array.from({ length: 9 }, (_, i) => <div key={i} className="tab-status-dot" />)}
1144+
}${__IS_LINUX__ ? ' tab-status-grid--static' : ''}`}>
1145+
{/* Linux gate — the 9 dot wave/snake/ripple animations are
1146+
opacity-loop infinites with box-shadow halos. WebKit2GTK
1147+
+ Cairo doesn't promote these to compositor layers, so
1148+
each frame re-rasters the box-shadow blur on CPU; with
1149+
N tabs that's 9N indefinite repaints. Linux gets a
1150+
single static colored dot instead — same idle/working/
1151+
waiting color signal, zero animation cost. */}
1152+
{__IS_LINUX__
1153+
? <div className="tab-status-dot tab-status-dot--solo" />
1154+
: Array.from({ length: 9 }, (_, i) => <div key={i} className="tab-status-dot" />)}
11461155
</div>
11471156
)}
11481157
<button

src-ui/src/components/center/ChatReader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export function ChatReader({ sessionId }: { sessionId: string }) {
239239
<path fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
240240
d="M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4">
241241
{/* Linux gate — see Explorer.tsx brand-icon for full rationale. */}
242-
{!navigator.userAgent.toLowerCase().includes('linux') && (
242+
{!__IS_LINUX__ && (
243243
<animate attributeName="d" dur="3s" repeatCount="indefinite"
244244
values="M8 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4;M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"/>
245245
)}

src-ui/src/components/center/DosPlayer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ export function DosPlayer({ sessionId }: { sessionId: string }) {
496496
<path fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
497497
d="M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4">
498498
{/* Linux gate — see Explorer.tsx brand-icon for full rationale. */}
499-
{!navigator.userAgent.toLowerCase().includes('linux') && (
499+
{!__IS_LINUX__ && (
500500
<animate attributeName="d" dur="3s" repeatCount="indefinite"
501501
values="M8 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4;M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"/>
502502
)}

src-ui/src/components/center/TierTerminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,7 @@ function TierTerminalImpl({
11031103
<path fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
11041104
d="M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4">
11051105
{/* Linux gate — see Explorer.tsx brand-icon for full rationale. */}
1106-
{!navigator.userAgent.toLowerCase().includes('linux') && (
1106+
{!__IS_LINUX__ && (
11071107
<animate attributeName="d" dur="3s" repeatCount="indefinite"
11081108
values="M8 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4;M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"/>
11091109
)}

src-ui/src/components/left/Explorer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,7 @@ export function Explorer() {
11031103
SMIL). Static `d` on Linux means the steam stops drifting
11041104
upward but the cup glyph itself is fully intact. */}
11051105
<path fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4">
1106-
{!navigator.userAgent.toLowerCase().includes('linux') && (
1106+
{!__IS_LINUX__ && (
11071107
<animate attributeName="d" dur="3s" repeatCount="indefinite" values="M8 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4;M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"/>
11081108
)}
11091109
</path>

src-ui/src/global.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/// <reference types="vite/client" />
2+
3+
// Build-time platform constant injected by vite.config.ts via `define`.
4+
// True iff the binary was built on a Linux CI runner. Used to gate
5+
// CPU-expensive WebKit2GTK animations (SMIL path morphs in masks, dot
6+
// grids with box-shadow opacity loops) that are essentially free on
7+
// WebView2 / WKWebView but peg cores on TextureMapper + Cairo.
8+
declare const __IS_LINUX__: boolean;

src-ui/vite.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ export default defineConfig({
1212
},
1313
// Allow Tauri IPC
1414
envPrefix: ['VITE_', 'TAURI_'],
15+
// Build-time platform constant. Each platform's installer is built on its
16+
// own CI runner (release.yml: ubuntu-22.04 / windows-latest / macos-13),
17+
// so process.platform here = the target platform of the binary being
18+
// produced. Far more reliable than navigator.userAgent — WebKit2GTK's UA
19+
// string is configurable and has been observed to omit "Linux" on some
20+
// distro/Tauri-version combos, defeating the runtime gate.
21+
define: {
22+
__IS_LINUX__: JSON.stringify(process.platform === 'linux'),
23+
},
1524
build: {
1625
// Tauri supports es2021
1726
target: ['es2021', 'chrome105', 'safari15'],

0 commit comments

Comments
 (0)