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, 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 && (