From 928762dca83b8af77d9fa01ed6f8bab996b572eb Mon Sep 17 00:00:00 2001 From: GCWing Date: Fri, 2 Oct 2026 20:11:36 +0800 Subject: [PATCH 1/2] fix(build): catch stale pnpm store packages and widen mobile-web rebuild inputs check:build-prereqs now reports a pnpm virtual store package whose directory exists but holds no files (or whose link target is gone): the matching node_modules/.bin shim then resolves to a path without content and nested tools fail with a confusing "Cannot find module .../bin/" error. pnpm install alone does not repair that state, so --fix removes the broken directories first, guarded so a path can never escape the virtual store. The mobile-web rebuild cache watched a hand-written list of design-system sources, so a change under packages/ui or a package-root config such as vite.config.ts could leave src/mobile-web/dist stale while the build looked successful. Watch the design-system packages/ and tooling/ trees instead, which covers new packages and package-root configs without maintenance, while generated dist/ and node_modules/ stay ignored. The contract test now runs in CI so the cache rules cannot regress silently. --- .github/workflows/ci.yml | 4 + scripts/check-build-prereqs.mjs | 125 +++++++++++++++++++++++++-- scripts/check-build-prereqs.test.mjs | 118 ++++++++++++++++++++++++- scripts/mobile-web-build.cjs | 21 +++-- scripts/mobile-web-build.test.mjs | 76 ++++++++++++++-- 5 files changed, 318 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259f937a9..ee9e62e45d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -660,6 +660,10 @@ jobs: if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm --dir src/mobile-web run type-check + - name: Validate mobile-web build cache contract + if: needs.build-impact.outputs.frontend_required != 'false' + run: node --test scripts/mobile-web-build.test.mjs + - name: Test mobile web account login and reload if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm --dir src/mobile-web run test:account-login diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs index 43380ce85b..4278ac0a47 100644 --- a/scripts/check-build-prereqs.mjs +++ b/scripts/check-build-prereqs.mjs @@ -7,6 +7,11 @@ * * - Root node_modules missing → pnpm scripts fail with "node_modules missing, * did you mean to install?" + * - A package directory inside node_modules/.pnpm unexpectedly empty (or a + * dangling link) → the matching node_modules/.bin shim resolves to a path + * without content, and nested tools fail with a confusing "Cannot find + * module .../bin/" error, for example + * ".../design-system/packages/ui/node_modules/typescript/bin/tsc" * - src/mobile-web/dist missing → cargo check -p openbitfun-desktop and * cargo check --workspace fail with "resource path '../../mobile-web/dist' * doesn't exist" in the openbitfun-desktop build script @@ -24,8 +29,8 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { join, dirname, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -36,6 +41,69 @@ const FIX = process.argv.includes('--fix'); // --- Check logic (extracted for re-use and testing) --- +const VIRTUAL_STORE_SAMPLE_LIMIT = 5; + +function readdirWithTypes(dir) { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +/** + * Package directories inside node_modules/.pnpm that pnpm recorded but whose + * files are gone: an existing but empty package directory, or a link whose + * target disappeared. Either one makes the workspace bin shims resolve to a + * path without content. + */ +function findBrokenVirtualStorePackages(rootDir) { + const virtualStoreDir = join(rootDir, 'node_modules', '.pnpm'); + if (!existsSync(virtualStoreDir)) { + return []; + } + + const isBroken = (packageDir, entry) => { + // readdir reports the name of a dangling link, but the path cannot be opened. + if (!existsSync(packageDir)) { + return true; + } + // Links to peer packages hold no files themselves; their target is checked + // through its own entry in the virtual store. + if (entry.isSymbolicLink()) { + return false; + } + return readdirWithTypes(packageDir).length === 0; + }; + + const broken = []; + for (const storeEntry of readdirWithTypes(virtualStoreDir)) { + if (!storeEntry.isDirectory()) { + continue; + } + const storeNodeModules = join(virtualStoreDir, storeEntry.name, 'node_modules'); + + for (const entry of readdirWithTypes(storeNodeModules)) { + if (entry.name.startsWith('@')) { + const scopeDir = join(storeNodeModules, entry.name); + for (const scopedEntry of readdirWithTypes(scopeDir)) { + const packageDir = join(scopeDir, scopedEntry.name); + if (isBroken(packageDir, scopedEntry)) { + broken.push(packageDir); + } + } + continue; + } + const packageDir = join(storeNodeModules, entry.name); + if (isBroken(packageDir, entry)) { + broken.push(packageDir); + } + } + } + + return broken; +} + function runChecks(rootDir) { const errors = []; const warnings = []; @@ -49,7 +117,30 @@ function runChecks(rootDir) { }); } - // --- Check 2: mobile-web dist (required by openbitfun-desktop build script) --- + // --- Check 2: pnpm virtual store integrity --- + const brokenPackages = findBrokenVirtualStorePackages(rootDir); + if (brokenPackages.length > 0) { + const samples = brokenPackages + .slice(0, VIRTUAL_STORE_SAMPLE_LIMIT) + .map((packageDir) => relative(rootDir, packageDir)); + const remaining = brokenPackages.length - samples.length; + const sampleText = remaining > 0 ? `${samples.join(', ')}, ...(+${remaining} more)` : samples.join(', '); + + errors.push({ + name: 'pnpm virtual store', + message: + `${brokenPackages.length} installed package(s) are missing their files: ${sampleText}. ` + + 'The matching node_modules/.bin shims point at a path without content, so nested tools fail with ' + + 'a confusing "Cannot find module .../bin/" error. pnpm install alone does not repair this: ' + + 'pnpm leaves an existing empty package directory untouched, so the broken directories must be removed first.', + fix: ['pnpm', 'install'], + cleanPaths: brokenPackages, + fixNote: + '--fix removes the broken package directories first, because pnpm install would otherwise leave them empty. To repair by hand, delete the paths above and run pnpm install.', + }); + } + + // --- Check 3: mobile-web dist (required by openbitfun-desktop build script) --- if (!existsSync(join(rootDir, 'src', 'mobile-web', 'dist', 'index.html'))) { errors.push({ name: 'mobile-web dist', @@ -59,7 +150,7 @@ function runChecks(rootDir) { }); } - // --- Check 3: OpenCode extension Host dist (product runtime resource) --- + // --- Check 4: OpenCode extension Host dist (product runtime resource) --- const pluginHostDist = join( rootDir, 'src', @@ -77,7 +168,7 @@ function runChecks(rootDir) { }); } - // --- Check 4: sherpa-onnx prebuilt libs --- + // --- Check 5: sherpa-onnx prebuilt libs --- // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt//lib/ // and returns immediately without downloading. Only warn for the first-build // scenario where no prebuilt cache exists yet. @@ -106,7 +197,11 @@ function runChecks(rootDir) { function collectPendingFixes(errors) { return errors .filter((e) => e.fix) - .map((e) => ({ name: e.name, fix: e.fix })); + .map((e) => ({ + name: e.name, + fix: e.fix, + cleanPaths: e.cleanPaths ?? [], + })); } function reportResults({ errors, warnings }) { @@ -117,6 +212,9 @@ function reportResults({ errors, warnings }) { if (e.fix) { console.error(` Fix: ${e.fix.join(' ')}`); } + if (e.fixNote) { + console.error(` Note: ${e.fixNote}`); + } } console.error(); } @@ -132,7 +230,20 @@ function reportResults({ errors, warnings }) { function runFixes(pendingFixes, rootDir) { let allSucceeded = true; - for (const { name, fix } of pendingFixes) { + for (const { fix, cleanPaths } of pendingFixes) { + for (const target of cleanPaths) { + // Only ever delete inside the virtual store: a broken path must not be + // allowed to escape into source or user data. + const virtualStorePrefix = `${join(rootDir, 'node_modules', '.pnpm')}${sep}`; + if (!target.startsWith(virtualStorePrefix)) { + console.error(`Refusing to remove path outside the pnpm virtual store: ${target}\n`); + allSucceeded = false; + continue; + } + console.log(`Removing broken package directory ${relative(rootDir, target)}`); + rmSync(target, { recursive: true, force: true }); + } + const [cmd, ...args] = fix; console.log(`$ ${fix.join(' ')}`); try { diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs index 71af05a841..da15d61ea0 100644 --- a/scripts/check-build-prereqs.test.mjs +++ b/scripts/check-build-prereqs.test.mjs @@ -1,5 +1,12 @@ import assert from 'node:assert/strict'; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -17,6 +24,7 @@ function createTestRoot({ mobileWebDist = false, pluginHostDist = false, sherpaOnnx = null, + virtualStoreEntries = null, } = {}) { const root = mkdtempSync(path.join(tmpdir(), 'openbitfun-build-prereqs-')); @@ -24,6 +32,18 @@ function createTestRoot({ mkdirSync(path.join(root, 'node_modules'), { recursive: true }); } + // Maps a virtual store package path, relative to the root, to the files it + // should contain. An empty file list models a package whose files are gone. + if (virtualStoreEntries) { + for (const [relativeDir, files] of Object.entries(virtualStoreEntries)) { + const packageDir = path.join(root, relativeDir); + mkdirSync(packageDir, { recursive: true }); + for (const file of files) { + writeFileSync(path.join(packageDir, file), '{}'); + } + } + } + if (mobileWebDist) { const distDir = path.join(root, 'src', 'mobile-web', 'dist'); mkdirSync(distDir, { recursive: true }); @@ -65,10 +85,24 @@ function createFakePnpm() { writeFileSync( fakePnpmPath, ` -const { mkdirSync, writeFileSync } = require('fs'); +const { existsSync, mkdirSync, writeFileSync } = require('fs'); +const path = require('path'); const args = process.argv.slice(2); if (args[0] === 'install') { mkdirSync('node_modules', { recursive: true }); + const restorePath = process.env.FAKE_PNPM_RESTORE_PATH; + if (restorePath) { + // Records whether the broken package was still present when install ran, + // which is what pnpm itself would trip over. + if (process.env.FAKE_PNPM_REPORT_PATH) { + writeFileSync( + process.env.FAKE_PNPM_REPORT_PATH, + JSON.stringify({ dirExistedWhenInstallRan: existsSync(restorePath) }), + ); + } + mkdirSync(restorePath, { recursive: true }); + writeFileSync(path.join(restorePath, 'package.json'), '{}'); + } } else if (args[0] === 'run' && args[1] === 'prepare:mobile-web') { mkdirSync('src/mobile-web/dist', { recursive: true }); writeFileSync('src/mobile-web/dist/index.html', ''); @@ -94,7 +128,10 @@ if (args[0] === 'install') { return binDir; } -function runCheck(root, { fix = false, extraPath = null, sherpaEnv = null } = {}) { +function runCheck( + root, + { fix = false, extraPath = null, sherpaEnv = null, extraEnv = null } = {}, +) { const env = { ...process.env, OPENBITFUN_BUILD_PREREQS_TEST_ROOT: root, @@ -109,6 +146,9 @@ function runCheck(root, { fix = false, extraPath = null, sherpaEnv = null } = {} env.SHERPA_ONNX_LIB_DIR = sherpaEnv; } } + if (extraEnv) { + Object.assign(env, extraEnv); + } const args = fix ? [scriptPath, '--fix'] : [scriptPath]; @@ -124,6 +164,12 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) mobileWebDist: true, pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + virtualStoreEntries: { + 'node_modules/.pnpm/typescript@5.8.3/node_modules/typescript': ['package.json'], + 'node_modules/.pnpm/@openbitfun+ui@0.1.0/node_modules/@openbitfun/ui': [ + 'package.json', + ], + }, }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -134,6 +180,72 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) assert.doesNotMatch(result.stderr, /\[WARN\]/); }); +test('fails when a pnpm virtual store package has no files', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + pluginHostDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + virtualStoreEntries: { + 'node_modules/.pnpm/typescript@5.8.3/node_modules/typescript': [], + }, + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] pnpm virtual store/); + assert.match( + result.stderr, + /node_modules[\\/]\.pnpm[\\/]typescript@5\.8\.3[\\/]node_modules[\\/]typescript/, + ); + assert.match(result.stderr, /Fix: pnpm install/); + assert.match(result.stderr, /does not repair this/); +}); + +test('--fix removes broken virtual store packages before running pnpm install', (t) => { + const binDir = createFakePnpm(); + const brokenRelativeDir = + 'node_modules/.pnpm/typescript@5.8.3/node_modules/typescript'; + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + pluginHostDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + virtualStoreEntries: { [brokenRelativeDir]: [] }, + }); + const reportPath = path.join(root, 'fake-pnpm-report.json'); + t.after(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + }); + + const result = runCheck(root, { + fix: true, + extraPath: binDir, + sherpaEnv: '', + extraEnv: { + FAKE_PNPM_RESTORE_PATH: path.join(root, brokenRelativeDir), + FAKE_PNPM_REPORT_PATH: reportPath, + }, + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match( + result.stdout, + /Removing broken package directory node_modules[\\/]\.pnpm[\\/]typescript@5\.8\.3[\\/]node_modules[\\/]typescript/, + ); + assert.match(result.stdout, /\$ pnpm install/); + assert.match(result.stdout, /All errors resolved/); + // The empty directory has to be gone before install runs; pnpm does not + // repair a package directory that still exists with no files. + assert.equal( + JSON.parse(readFileSync(reportPath, 'utf8')).dirExistedWhenInstallRan, + false, + ); +}); + test('fails when root node_modules is missing', (t) => { const root = createTestRoot({ mobileWebDist: true, diff --git a/scripts/mobile-web-build.cjs b/scripts/mobile-web-build.cjs index e9e9ac6d08..f398c07b9c 100644 --- a/scripts/mobile-web-build.cjs +++ b/scripts/mobile-web-build.cjs @@ -147,15 +147,20 @@ function getMobileWebRebuildPlan(mobileWebDir, force = false, rootDir = ROOT_DIR path.join(mobileWebDir, 'tsconfig.json'), path.join(rootDir, 'pnpm-lock.yaml'), path.join(rootDir, 'pnpm-workspace.yaml'), + // src/mobile-web's prebuild rebuilds the design system, and mobile-web + // consumes @openbitfun/ui through its package exports (no source alias, and + // design-system/**/dist is generated rather than committed). Every file that + // can change a design-system build output must therefore invalidate the + // mobile-web dist, so watch the packages/ and tooling/ trees as a whole + // instead of a file list that falls behind: new packages and package-root + // configs such as vite.config.ts or tsconfig.build.json are covered without + // maintenance. Generated dist/ and node_modules/ stay ignored, so build + // output cannot re-trigger itself, and design-system/apps consumes these + // packages rather than feeding them. path.join(rootDir, 'design-system', 'package.json'), - path.join(rootDir, 'design-system', 'packages', 'design-tokens', 'package.json'), - path.join(rootDir, 'design-system', 'packages', 'design-tokens', 'scripts'), - path.join(rootDir, 'design-system', 'packages', 'design-tokens', 'src'), - path.join(rootDir, 'design-system', 'packages', 'theme-openbitfun', 'package.json'), - path.join(rootDir, 'design-system', 'packages', 'theme-openbitfun', 'scripts'), - path.join(rootDir, 'design-system', 'packages', 'theme-openbitfun', 'src'), - path.join(rootDir, 'design-system', 'tooling', 'token-engine', 'package.json'), - path.join(rootDir, 'design-system', 'tooling', 'token-engine', 'src'), + path.join(rootDir, 'design-system', 'tsconfig.base.json'), + path.join(rootDir, 'design-system', 'packages'), + path.join(rootDir, 'design-system', 'tooling'), ]; for (const entry of fs.readdirSync(mobileWebDir)) { if (entry.startsWith('vite.config.')) { diff --git a/scripts/mobile-web-build.test.mjs b/scripts/mobile-web-build.test.mjs index 07a08bc521..5cbee8584f 100644 --- a/scripts/mobile-web-build.test.mjs +++ b/scripts/mobile-web-build.test.mjs @@ -24,7 +24,11 @@ test('mobile-web lifecycle prepares its generated design-system package entries' const packageJson = JSON.parse( readFileSync(new URL('../src/mobile-web/package.json', import.meta.url), 'utf8'), ); - const prepareCommand = 'pnpm --dir ../../design-system run prepare:consumer-dev'; + // Unlike src/web-ui, mobile-web has no @openbitfun/ui source alias and + // design-system/packages/ui/dist is generated rather than committed, so dev, + // build, and type-check all resolve @openbitfun/ui through its package + // exports. Every hook therefore needs the full build:packages run. + const prepareCommand = 'pnpm --dir ../../design-system run build:packages'; assert.equal(packageJson.scripts['prepare:design-system'], prepareCommand); assert.equal(packageJson.scripts.predev, 'pnpm run prepare:design-system'); @@ -32,7 +36,11 @@ test('mobile-web lifecycle prepares its generated design-system package entries' assert.equal(packageJson.scripts.prebuild, 'pnpm run prepare:design-system'); }); -test('requests a mobile-web rebuild when a theme source changes', async () => { +/** + * Writes a mobile-web dist whose build marker is newer than everything except + * the single changed design-system input under test. + */ +async function createInputChangeFixture(changedInput) { const root = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-mobile-web-build-')); const mobileWebDir = path.join(root, 'src/mobile-web'); const output = write(root, 'src/mobile-web/dist/index.html', '
old
'); @@ -41,15 +49,18 @@ test('requests a mobile-web rebuild when a theme source changes', async () => { 'src/mobile-web/node_modules/.cache/openbitfun-mobile-web-build-marker', 'old build\n', ); - const themeSource = write( - root, - 'design-system/packages/theme-openbitfun/src/light.tokens.json', - '{}\n', - ); + const changedFile = write(root, changedInput, '{}\n'); const now = Date.now() / 1000; setMtime(output, now - 20); setMtime(marker, now - 10); - setMtime(themeSource, now); + setMtime(changedFile, now); + return { mobileWebDir, root }; +} + +test('requests a mobile-web rebuild when a theme source changes', async () => { + const { mobileWebDir, root } = await createInputChangeFixture( + 'design-system/packages/theme-openbitfun/src/light.tokens.json', + ); const plan = getMobileWebRebuildPlan(mobileWebDir, false, root); @@ -57,6 +68,41 @@ test('requests a mobile-web rebuild when a theme source changes', async () => { assert.match(plan.reason, /design-system[\\/]packages[\\/]theme-openbitfun[\\/]src[\\/]light\.tokens\.json/); }); +test('requests a mobile-web rebuild when a shared ui package source changes', async () => { + const { mobileWebDir, root } = await createInputChangeFixture( + 'design-system/packages/ui/src/mobile/MobileButton/MobileButton.tsx', + ); + + const plan = getMobileWebRebuildPlan(mobileWebDir, false, root); + + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /design-system[\\/]packages[\\/]ui[\\/]src[\\/]mobile[\\/]MobileButton[\\/]MobileButton\.tsx/); +}); + +test('requests a mobile-web rebuild when a design-system package-root config changes', async () => { + // Regression: a package-root file that is neither src/ nor scripts/ still + // changes the @openbitfun/ui exports mobile-web bundles. + const { mobileWebDir, root } = await createInputChangeFixture( + 'design-system/packages/ui/vite.mobile.config.ts', + ); + + const plan = getMobileWebRebuildPlan(mobileWebDir, false, root); + + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /design-system[\\/]packages[\\/]ui[\\/]vite\.mobile\.config\.ts/); +}); + +test('requests a mobile-web rebuild when the shared design-system tsconfig changes', async () => { + const { mobileWebDir, root } = await createInputChangeFixture( + 'design-system/tsconfig.base.json', + ); + + const plan = getMobileWebRebuildPlan(mobileWebDir, false, root); + + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /design-system[\\/]tsconfig\.base\.json/); +}); + test('reuses mobile-web output when only generated design-system output is newer', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-mobile-web-build-')); const mobileWebDir = path.join(root, 'src/mobile-web'); @@ -72,11 +118,25 @@ test('reuses mobile-web output when only generated design-system output is newer 'design-system/packages/theme-openbitfun/dist/index.js', 'export {};\n', ); + // Generated ui output is watched through its source tree only: treating dist + // as an input would make every design-system build invalidate itself. + const generatedUiBundle = write( + root, + 'design-system/packages/ui/dist/mobile.js', + 'export {};\n', + ); + const generatedUiTypes = write( + root, + 'design-system/packages/ui/dist/types/index.d.ts', + 'export {};\n', + ); const now = Date.now() / 1000; setMtime(mobileSource, now - 30); setMtime(output, now - 20); setMtime(marker, now - 10); setMtime(generatedTheme, now); + setMtime(generatedUiBundle, now); + setMtime(generatedUiTypes, now); assert.deepEqual(getMobileWebRebuildPlan(mobileWebDir, false, root), { shouldBuild: false, From 1d1393b57202e43abbe921478ab6eba206bf467e Mon Sep 17 00:00:00 2001 From: GCWing Date: Fri, 2 Oct 2026 20:12:03 +0800 Subject: [PATCH 2/2] feat(agent-companion): hide pet bubbles while the main window is focused The main window now forwards its focus changes as agent-companion://main-window-state, and the pet window keeps its bubble list empty while that flag is set, so the companion overlay no longer covers the session it points at while the user is working in the main window. The dock layout effect follows the same list, so the pet window resizes back to the pet alone, and the bubble list is memoized so a focused main window does not hand the effect a fresh array identity on every render. The new listener joins the existing readiness handshake, so agent-companion://ready is only emitted once every listener is attached. --- src/web-ui/src/app/App.tsx | 34 ++++++++++++++ .../AgentCompanionDesktopPet.tsx | 44 ++++++++++++++----- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 93e66d9471..096bb51a11 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -776,6 +776,40 @@ function App() { }; }, []); + useEffect(() => { + if (!isTauriRuntime()) { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + + void import('@tauri-apps/api/window') + .then(({ getCurrentWindow }) => getCurrentWindow().onFocusChanged(({ payload }) => { + if (disposed) return; + void import('@tauri-apps/api/event').then(({ emit }) => { + void emit('agent-companion://main-window-state', { focused: payload }); + }); + })) + .then(removeListener => { + if (disposed) { + removeListener(); + return; + } + unlisten = removeListener; + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to listen for main window focus changes', error); + } + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + useEffect(() => { let disposed = false; let unlisten: (() => void) | null = null; diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 4b5bbb6984..304f20b429 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -50,6 +50,7 @@ const IS_MACOS_WEBVIEW = /\bMacintosh\b/i.test(window.navigator.userAgent); // capture on macOS too, so running direction and drag lifetime follow the pointer. const USE_CONTROLLED_PET_DRAG = IS_WINDOWS_WEBVIEW || IS_MACOS_WEBVIEW; const PET_COMMAND_EVENT = 'agent-companion://pet-command'; +const MAIN_WINDOW_STATE_EVENT = 'agent-companion://main-window-state'; const MENU_EDGE_MARGIN = 4; interface TypewriterOutputState { @@ -176,6 +177,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { const [overlay, setOverlay] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); const [menuPosition, setMenuPosition] = useState(null); + const [isMainWindowFocused, setIsMainWindowFocused] = useState(false); const [dismissedBubbles, setDismissedBubbles] = useState>({}); const [composerValue, setComposerValue] = useState(''); const [isSendingComposer, setIsSendingComposer] = useState(false); @@ -198,7 +200,13 @@ export const AgentCompanionDesktopPet: React.FC = () => { () => tasks.filter(task => dismissedBubbles[task.sessionId] !== bubbleDismissBucket(task.state)), [dismissedBubbles, tasks], ); - const displayTasks = [...visibleTasks].reverse(); + // Memoized so a focused main window (empty bubble list) does not hand the + // layout effect below a fresh array identity on every render. + const bubbleTasks = useMemo( + () => (isMainWindowFocused ? [] : visibleTasks), + [isMainWindowFocused, visibleTasks], + ); + const displayTasks = [...bubbleTasks].reverse(); const activePetSize = pet && petFrameSize ? petFrameSize : pet @@ -252,6 +260,21 @@ export const AgentCompanionDesktopPet: React.FC = () => { }); let removeActivityListener: (() => void) | null = null; + let removeMainWindowStateListener: (() => void) | null = null; + const mainWindowStateListenerReady = listen<{ focused: boolean }>(MAIN_WINDOW_STATE_EVENT, event => { + setIsMainWindowFocused(event.payload.focused); + }).then(unlisten => { + if (disposed) { + unlisten(); + return false; + } + removeMainWindowStateListener = unlisten; + return true; + }).catch(error => { + log.warn('Failed to listen for main window state updates', error); + return false; + }); + const activityListenerReady = listen('agent-companion://activity-updated', event => { const emittedAt = event.payload.emittedAt ?? 0; const sequence = event.payload.sequence ?? 0; @@ -279,9 +302,9 @@ export const AgentCompanionDesktopPet: React.FC = () => { return false; }); - void Promise.all([settingsListenerReady, activityListenerReady]) - .then(([settingsReady, activityReady]) => { - if (!disposed && settingsReady && activityReady) { + void Promise.all([settingsListenerReady, activityListenerReady, mainWindowStateListenerReady]) + .then(([settingsReady, activityReady, mainWindowStateReady]) => { + if (!disposed && settingsReady && activityReady && mainWindowStateReady) { void emit('agent-companion://ready'); } }) @@ -295,6 +318,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { disposed = true; removeTauriListener?.(); removeActivityListener?.(); + removeMainWindowStateListener?.(); document.documentElement.classList.remove('openbitfun-agent-companion-window-root'); document.body.classList.remove('openbitfun-agent-companion-window-body'); }; @@ -429,7 +453,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { // leave the count out of sync with the committed layout epoch. visibleTaskCountRef.current = visibleTasks.length; layoutEpochRef.current += 1; - const bubbleCount = visibleTasks.length; + const bubbleCount = bubbleTasks.length; const bubbleElements = Array.from(bubblesRef.current?.children ?? []) .slice(0, MAX_VISIBLE_BUBBLES); // Sum of the bubbles themselves: the slot's own vertical buffer is chrome @@ -477,7 +501,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { .catch(error => { log.warn('Failed to resize Agent companion window', error); }); - }, [activePetSize.height, activePetSize.width, overlay, visibleTasks]); + }, [activePetSize.height, activePetSize.width, overlay, bubbleTasks, visibleTasks.length]); useEffect(() => { if (IS_WINDOWS_WEBVIEW && !trackPetLook) { @@ -975,10 +999,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { '--openbitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, '--openbitfun-agent-companion-vertical-buffer': `${WINDOW_VERTICAL_BUFFER}px`, } as React.CSSProperties; - const isSingleTask = visibleTasks.length === 1; - const hasAttentionTask = visibleTasks.some(task => task.state === 'attention'); + const isSingleTask = bubbleTasks.length === 1; + const hasAttentionTask = bubbleTasks.some(task => task.state === 'attention'); const overlayTask = overlay && overlay.kind !== 'pet-menu' - ? visibleTasks.find(task => task.sessionId === overlay.sessionId) ?? null + ? bubbleTasks.find(task => task.sessionId === overlay.sessionId) ?? null : null; const menuItems = overlay?.kind === 'pet-menu' ? [ @@ -1033,7 +1057,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { ref={dockRef} className="openbitfun-agent-companion-window__dock" data-openbitfun-component="agent-companion-desktop-pet" data-openbitfun-part="dock"> - {visibleTasks.length > 0 && ( + {bubbleTasks.length > 0 && (