diff --git a/.fusion/memory.md b/.fusion/memory.md index befecc30c..397acffcb 100644 --- a/.fusion/memory.md +++ b/.fusion/memory.md @@ -327,6 +327,34 @@ The mobile bottom-spacing is controlled by a single CSS variable `--mobile-nav-h When adjusting mobile bottom spacing, change `--mobile-nav-height` in one place and all related elements will update. Tab touch targets (`.mobile-nav-tab`) remain at 36px minimum regardless of nav height changes. +### FN-1626: PWA Home Bar Bottom Spacing + +For installed PWA mode (`@media (display-mode: standalone)`), an additional `--standalone-bottom-gap` token provides extra breathing room for the iOS home indicator: +- **`:root` default**: `--standalone-bottom-gap: 0px` (non-PWA fallback) +- **Standalone mode**: `--standalone-bottom-gap: 8px` (extra 8px for home bar) +- **Additive spacing pattern**: All bottom-positioned elements use `+ var(--standalone-bottom-gap)` in their calc expressions + +This pattern ensures PWA mode gets extra bottom room without breaking non-PWA behavior: +```css +/* :root */ +--standalone-bottom-gap: 0px; + +@media (display-mode: standalone) { + --standalone-bottom-gap: 8px; +} + +/* Usage example */ +#root { + padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); +} + +.executor-status-bar { + bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); +} +``` + +The token-based approach allows all bottom-layout consumers to be updated together by adding `+ var(--standalone-bottom-gap)` to their calc expressions. + ## FN-1458: Mobile Header Search Safe-Area-Inset Fix - When fixing mobile header search positioning issues (search box clipping off-screen), add safe-area-inset handling to both `.header` and `.header-floating-search` in the mobile media query diff --git a/packages/dashboard/app/__tests__/pwa.test.ts b/packages/dashboard/app/__tests__/pwa.test.ts index f798af5d2..7c7c6a828 100644 --- a/packages/dashboard/app/__tests__/pwa.test.ts +++ b/packages/dashboard/app/__tests__/pwa.test.ts @@ -42,6 +42,22 @@ describe("PWA configuration", () => { expect(cssContent).toMatch(/@media\s*\(\s*display-mode:\s*standalone\s*\)\s*\{[^}]*#root\s*\{[^}]*env\(safe-area-inset-bottom,\s*0px\)/); }); + it("CSS includes --standalone-bottom-gap token with 8px value in standalone mode", () => { + const cssContent = readFileSync(resolve(__dirname, "../styles.css"), "utf8"); + + // Token definition in :root + expect(cssContent).toContain("--standalone-bottom-gap: 0px"); + // Token override in standalone mode sets 8px gap + expect(cssContent).toMatch(/--standalone-bottom-gap:\s*8px/); + }); + + it("CSS uses additive bottom spacing in standalone mode (safe-area + gap)", () => { + const cssContent = readFileSync(resolve(__dirname, "../styles.css"), "utf8"); + + // #root should use var(--standalone-bottom-gap) in a calc expression for additive spacing + expect(cssContent).toContain("var(--standalone-bottom-gap))"); + }); + it("service worker contains lifecycle handlers and versioned cache name", () => { const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8"); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 44ea2bb27..993987d59 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -67,6 +67,9 @@ --executor-footer-height: 0px; --executor-footer-height-mobile: 0px; + /* PWA standalone mode bottom gap (FN-1626): extra breathing room for iOS home indicator */ + --standalone-bottom-gap: 0px; + /* Shadow tokens */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); @@ -6371,8 +6374,12 @@ body { @media (display-mode: standalone) { #root { - padding-bottom: env(safe-area-inset-bottom, 0px); + /* FN-1626: additive bottom spacing = safe-area inset + 8px home bar gap */ + padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); } + + /* PWA standalone mode: 8px extra breathing room for iOS home indicator */ + --standalone-bottom-gap: 8px; } /* === Mobile Responsive Overrides === @@ -18791,7 +18798,7 @@ html .column.drag-over * { font-size: 11px; height: 32px; overflow: hidden; - bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px)); + bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); } .executor-status-bar__segment { @@ -26614,12 +26621,12 @@ html .column.drag-over * { /* Content padding: mobile nav only (no footer) */ .project-content--with-mobile-nav:not(.project-content--with-footer) { - padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px)); + padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); } /* Content padding: both mobile nav AND footer */ .project-content--with-footer.project-content--with-mobile-nav { - padding-bottom: calc(32px + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px)); + padding-bottom: calc(32px + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); } } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 4958afbdb..deadbf813 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -464,9 +464,10 @@ export class TriageProcessor { } this.wasEnginePaused = false; - const tasks = await this.store.listTasks({ slim: true, column: "triage" }); + // Fetch all tasks (not just triage) to count active agents across columns. + const allTasks = await this.store.listTasks({ slim: true, includeArchived: false }); const now = Date.now(); - const triageTasks = tasks.filter( + const triageTasks = allTasks.filter( (t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused // Skip tasks awaiting manual plan approval — they should not be auto-discovered && t.status !== "awaiting-approval" @@ -474,13 +475,26 @@ export class TriageProcessor { && !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now), ); - // Respect the global concurrency limit — only kick off as many triage - // tasks as the semaphore has available slots. Without this gate, all - // eligible tasks queue on the semaphore simultaneously, which is - // wasteful and confusing in the UI. - const maxToStart = this.options.semaphore + // Respect both per-project maxConcurrent and the global semaphore. + // Count all active agent slots: in-progress tasks + already-specifying tasks. + const maxConcurrent = settings.maxConcurrent ?? 2; + const inProgress = allTasks.filter((t) => t.column === "in-progress").length; + const specifying = allTasks.filter( + (t) => t.column === "triage" && t.status === "specifying" && !t.paused, + ).length; + const activeAgents = inProgress + specifying; + + const perProjectAvailable = Math.max(0, maxConcurrent - activeAgents); + const semaphoreAvailable = this.options.semaphore ? Math.max(0, this.options.semaphore.availableCount) - : triageTasks.length; + : Infinity; + const maxToStart = Math.min(perProjectAvailable, semaphoreAvailable); + + if (maxToStart <= 0 && triageTasks.length > 0) { + triageLog.log( + `Triage throttled: ${activeAgents} active agents (${inProgress} executing, ${specifying} specifying), limit ${maxConcurrent}`, + ); + } for (let i = 0; i < Math.min(triageTasks.length, maxToStart); i++) { void this.specifyTask(triageTasks[i]);