diff --git a/android/app/build.gradle b/android/app/build.gradle index f0eea10702..94145f7ba5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,7 +31,7 @@ android { applicationId "com.marinara.engine" minSdk 24 targetSdk 34 - versionCode 48 + versionCode 49 versionName "2.5.0" buildConfigField "String", "MARINARA_SERVER_URL", "\"http://127.0.0.1:${marinaraPort}\"" buildConfigField "String", "MARINARA_RELEASE_TAG", "\"v2.5.0\"" diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3c6f1810f0..de68bc10c4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -31,11 +31,26 @@ If Vite stops with `Reached heap limit` or `JavaScript heap out of memory`, the Close other apps before retrying. Phones with less than about 3 GB of RAM can still run out of memory or be stopped by Android; keep the complete launcher output when reporting that case. Do not delete your chats or profile to repair a build failure. -### Termux: missing multiplayer guest asset or incompatible Sharp +### Termux: missing multiplayer guest asset If startup still reports a missing `packages/client/dist/multiplayer/guest.js` after rebuilding, update Engine and rerun `./start-termux.sh`. The launcher now runs the complete low-memory client build, including the guest assets checked at startup. You do not need to enable multiplayer to repair this build error. -If image processing reports that Sharp cannot load on Android, update Engine and let the launcher reinstall dependencies. The matching `@img/sharp-wasm32` fallback is included as a regular dependency so frozen installs and updates retain it. Avoid replacing it with an unrelated Sharp version. Keep the complete error output if the problem persists. +### Termux: server stops when you open Engine, or Sharp cannot load + +Sharp is the image library Engine uses for thumbnails and sprites. On Android it runs through a WebAssembly fallback. Updating in place from 2.4.6 could leave part of that fallback uninstalled, and the server then stopped the first time a browser opened Engine. + +Update Engine and let the launcher reinstall dependencies. The update installs the missing part, and Engine keeps it through later updates. If the server keeps stopping when you open Engine before you can update, repair the install by hand in Termux: + +```bash +cd ~/Marinara-Engine +rm -f node_modules/.modules.yaml +SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm --config.trustPolicy=off --config.confirmModulesPurge=false install --frozen-lockfile --prefer-offline +./start-termux.sh +``` + +This clears pnpm's outdated install record and reinstalls the dependencies. Your chats and settings are not touched. + +If Sharp still cannot load, Engine now keeps running with image processing off: thumbnails it has already made still show, new ones show the full-size image instead, and sprite generation and the built-in background removal are unavailable. Do not replace Sharp with an unrelated version. Keep the complete error output when you report the problem. ### Blank page or JavaScript served as HTML after an update diff --git a/docs/chats/chat-settings.md b/docs/chats/chat-settings.md index 4e5bd3abfd..b161201879 100644 --- a/docs/chats/chat-settings.md +++ b/docs/chats/chat-settings.md @@ -53,7 +53,7 @@ The title bar has a few other useful buttons: - **Help** (the **?** beside the title) shows labels explaining the chat's controls. On a phone, it closes Chat Settings first so you can see the chat. Tap the highlighted **Chat Settings** button for a guide to its icons: Reset View, favorite layout, the Roleplay Tracker Panel, lock/unlock, close and moving a section out. Scroll inside the guide to read every item. You can hide the Help button in **Settings → General → App Behavior → Hide chat Help button**. - **Reset View** (the circular arrow) asks for confirmation, then restores this chat's starting layout. It puts popped-out sections back and restores the default window sizes, positions, pins and locks. - **Favorite layout** (the star beside Reset View) saves this arrangement for new chats in the current mode. See [Choosing a layout for new chats](#choosing-a-layout-for-new-chats). -- **Tracker Panel** (the die) turns the panel on or off in a Roleplay chat that uses agents or Advanced Memory. On a computer, the panel opens beside the chat. On a phone, a movable button opens it. This setting also changes the Tracker Panel preference in **Settings → Appearance**. +- **Tracker Panel** (the die) turns the panel on or off in a Roleplay chat that uses agents or Advanced Memory. On a computer, the panel opens beside the chat. On a phone or tablet, a movable button opens and closes it. This setting also changes the Tracker Panel preference in **Settings → Appearance**. When the Tracker Panel is not shown, desktop trackers use the [Trackers window](../roleplay/hud-and-trackers.md#the-trackers-window). When there is not enough room beside your messages, it starts as a small **Trackers** button. Closing the window returns it to that button. Use the die in the Chat Settings title bar to show the Tracker Panel again. diff --git a/docs/roleplay/hud-and-trackers.md b/docs/roleplay/hud-and-trackers.md index 11e59a1893..6cdcf7978c 100644 --- a/docs/roleplay/hud-and-trackers.md +++ b/docs/roleplay/hud-and-trackers.md @@ -105,7 +105,7 @@ The **Tracker Panel** is a larger side panel that shows the same tracker data as To turn it on in a Roleplay chat, open **Chat Settings** and click the **Tracker Panel** button (the die) in its title bar, next to pin and lock. It stays highlighted while the panel is on, and the panel shows beside the chat. Click it again to turn the panel off and hide it. On a computer, the trackers then show in the Trackers window. -On a phone, turning it on puts a Tracker Panel button on the chat that you can drag anywhere. Tap it to open the panel; closing the panel goes back to the button. With the panel off, use the separate **World State** and **Player & Tracker** buttons. +On a phone or tablet, turning it on puts a Tracker Panel button on the chat that you can drag anywhere. Tap it to open the panel, and tap it again to close it. While the panel or another screen covers the chat, the chat's movable buttons are hidden, and they come back in the same places when you return to the chat. With the panel off, use the separate **World State** and **Player & Tracker** buttons. The controls in the panel header also let you customize tracker structure: diff --git a/e2e/chat-controls-covered.e2e.ts b/e2e/chat-controls-covered.e2e.ts new file mode 100644 index 0000000000..3b5a3dba62 --- /dev/null +++ b/e2e/chat-controls-covered.e2e.ts @@ -0,0 +1,229 @@ +// Issue #7176: between phone and desktop widths (an 856px foldable, say) panels and editors open +// over a chat that stays mounted. The chat's buttons and windows must not show through them, and +// the Trackers button must close the Tracker Panel it opened. +import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { seedUIState } from "./ui-state-fixture.js"; + +const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +const ANDROID_APP_UA = + "Mozilla/5.0 (Linux; Android 14; Honor Magic V2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 MarinaraEngine/Android"; +const AGENTS_PANEL = '[data-component="RightPanelMobile"], [data-component="RightPanelDesktopSlot"]'; +test.use({ reducedMotion: "reduce" }); + +async function createChat(request: APIRequestContext) { + const response = await request.post("/api/chats", { + data: { name: "Covered chat controls", mode: "roleplay", characterIds: [] }, + }); + expect(response.ok()).toBeTruthy(); + const chat = (await response.json()) as { id: string }; + expect( + (await request.patch(`/api/chats/${chat.id}/metadata`, { data: { enableAgents: true, activeAgentIds: [] } })).ok(), + ).toBeTruthy(); + return chat; +} + +async function openChat(page: Page, chatId: string) { + await page.route("**/api/app-settings/ui", (route) => route.fulfill({ json: { value: "" } })); + await seedUIState(page, { + hasCompletedOnboarding: true, + sidebarOpen: false, + rightPanelOpen: false, + chatHelpSeenModes: ["conversation", "roleplay", "game"], + chatSettingsMoveTipDismissed: true, + appAccentPulseMode: false, + trackerPanelEnabled: true, + trackerPanelOpen: true, + trackerPanelOpenByChatId: { [chatId]: true }, + }); + await page.addInitScript( + ({ chatId, version }) => { + localStorage.setItem("marinara-active-chat-id", chatId); + localStorage.setItem("marinara:whats-new:seen-version", version); + }, + { chatId, version }, + ); + await page.goto("/"); + await expect(page.locator('[data-chat-mode="roleplay"]')).toBeVisible({ timeout: 30_000 }); +} + +const center = async (locator: Locator) => { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }; +}; + +/** Whether a press at this point reaches the given element (nothing drawn over it). */ +const pressReaches = (page: Page, point: { x: number; y: number }, selector: string) => + page.evaluate(({ x, y, selector }) => document.elementFromPoint(x, y)?.closest(selector) != null, { + ...point, + selector, + }); + +async function snap(page: Page, info: TestInfo, name: string) { + await page.screenshot({ path: info.outputPath(`${name}.png`), animations: "disabled" }); +} + +/** The 856px flow: the Tracker Panel, then the Agents list and an agent's editor, all over the chat and a pinned window. */ +async function checkCoveredChat(page: Page, info: TestInfo) { + const settingsButton = page.locator(".mari-window-bubble[data-chat-settings-button]"); + const trackersButton = page.locator('.mari-window-bubble[data-tracker-panel-toggle="bubble"]'); + const panel = page.locator('[data-component="TrackerDataSidebar"]:visible'); + const chatButtons = page.locator(".mari-window-bubble:visible"); + + // The panel opens over the chat on load; the chat's buttons stay under it. + await expect(panel).toBeVisible(); + await snap(page, info, "tracker-panel-open"); + await expect(chatButtons).toHaveCount(0); + + await panel.getByRole("button", { name: "Close tracker panel", exact: true }).click(); + await expect(panel).toHaveCount(0); + await expect(settingsButton).toBeVisible(); + await expect(trackersButton).toHaveAttribute("aria-expanded", "false"); + const settingsPoint = await center(settingsButton); + const trackersPoint = await center(trackersButton); + + // From the keyboard: the panel hides its button, so focus moves into the panel and back again on close. + await trackersButton.focus(); + await page.keyboard.press("Enter"); + await expect(panel).toBeVisible(); + await expect(page.locator('[data-component="TrackerDataSidebarMobile"]')).toBeFocused(); + await expect(chatButtons).toHaveCount(0); + await expect.poll(() => pressReaches(page, trackersPoint, ".mari-tracker-panel")).toBe(true); + await expect.poll(() => pressReaches(page, settingsPoint, ".mari-tracker-panel")).toBe(true); + await panel.getByRole("button", { name: "Close tracker panel", exact: true }).click(); + await expect(trackersButton).toBeVisible(); + await expect(trackersButton).toBeFocused(); + + // A pinned window stays open behind other screens, out of sight. + await settingsButton.click(); + const settingsWindow = page.locator('.mari-window[data-window="chat-settings"]'); + await expect(settingsWindow).toBeVisible(); + await settingsWindow.locator('[data-window-control="pin"]').click(); + await expect(settingsWindow).toHaveAttribute("data-pinned", "true"); + + // Another screen from the top bar, without going Home first. + await page.locator('[data-tour="panel-agents"]').click(); + const newAgent = page.locator(AGENTS_PANEL).getByRole("button", { name: "New", exact: true }); + await expect(newAgent).toBeVisible(); + await snap(page, info, "agents-list"); + await expect(chatButtons).toHaveCount(0); + await expect(settingsWindow).toBeHidden(); + await expect.poll(() => pressReaches(page, trackersPoint, AGENTS_PANEL)).toBe(true); + await expect.poll(() => pressReaches(page, settingsPoint, AGENTS_PANEL)).toBe(true); + + await newAgent.click(); + const editor = page.locator('[data-component="MobileDetailSheet"]'); + const save = editor.getByRole("button", { name: "Save", exact: true }); + const back = editor.getByRole("button", { name: "Back to agents", exact: true }); + await expect(save).toBeVisible(); + await snap(page, info, "agent-editor"); + await expect(chatButtons).toHaveCount(0); + await expect(settingsWindow).toBeHidden(); + for (const point of [settingsPoint, trackersPoint, await center(save), await center(back)]) { + await expect.poll(() => pressReaches(page, point, '[data-component="MobileDetailSheet"]')).toBe(true); + } + + // Back returns to the Agents list, still over the chat; closing it brings the buttons back where they were. + await back.click(); + await expect(editor).toHaveCount(0); + await expect(newAgent).toBeVisible(); + await expect(chatButtons).toHaveCount(0); + await page.locator('[data-tour="panel-agents"]').click(); + await expect(page.locator('[data-component="RightPanelMobile"]')).toHaveCount(0); + await expect(settingsButton).toBeVisible(); + expect(await center(settingsButton)).toEqual(settingsPoint); + await expect(settingsWindow).toBeVisible(); + await expect(settingsWindow).toHaveAttribute("data-pinned", "true"); +} + +test.describe("between phone and desktop widths", () => { + test.use({ viewport: { width: 856, height: 904 } }); + + test("chat buttons stay off other screens and under the Tracker Panel", async ({ page, request }, info) => { + test.skip(info.project.name !== "desktop-chromium", "One Chromium run covers this width."); + const chat = await createChat(request); + try { + await openChat(page, chat.id); + await checkCoveredChat(page, info); + } finally { + await request.delete(`/api/chats/${chat.id}?force=true`); + } + }); +}); + +test.describe("in the Android app on a foldable", () => { + test.use({ viewport: { width: 856, height: 904 }, isMobile: true, hasTouch: true, userAgent: ANDROID_APP_UA }); + + test("chat buttons stay off other screens and under the Tracker Panel", async ({ page, request }, info) => { + test.skip(info.project.name !== "desktop-chromium", "One Chromium run covers this device."); + const chat = await createChat(request); + try { + await openChat(page, chat.id); + await checkCoveredChat(page, info); + } finally { + await request.delete(`/api/chats/${chat.id}?force=true`); + } + }); +}); + +test.describe("on a computer", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("the Trackers button opens and closes its panel", async ({ page, request }, info) => { + test.skip(info.project.name !== "desktop-chromium", "Computer layout."); + const chat = await createChat(request); + try { + await openChat(page, chat.id); + const trackersButton = page.locator('.mari-window-bubble[data-tracker-panel-toggle="bubble"]'); + const panel = page.locator('[data-component="TrackerDataSidebar"]:visible'); + await expect(panel).toBeVisible(); + await expect(trackersButton).toBeVisible(); + await expect(trackersButton).toHaveAttribute("aria-expanded", "true"); + await snap(page, info, "desktop-tracker-panel-open"); + + await trackersButton.click(); + await expect(panel).toHaveCount(0); + await expect(trackersButton).toHaveAttribute("aria-expanded", "false"); + await expect(trackersButton).toBeFocused(); + + await trackersButton.click(); + await expect(panel).toBeVisible(); + await expect(trackersButton).toHaveAttribute("aria-expanded", "true"); + + // Docked panels sit beside the chat, which keeps its buttons; an editor replaces the chat. + await page.locator('[data-tour="panel-agents"]').click(); + const newAgent = page.locator(AGENTS_PANEL).getByRole("button", { name: "New", exact: true }); + await expect(newAgent).toBeVisible(); + await expect(page.locator(".mari-window-bubble[data-chat-settings-button]")).toBeVisible(); + await newAgent.click(); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeVisible(); + await expect(page.locator(".mari-window-bubble")).toHaveCount(0); + } finally { + await request.delete(`/api/chats/${chat.id}?force=true`); + } + }); +}); + +test("on a phone the chat buttons and Tracker Panel behave as before", async ({ page, request, isMobile }, info) => { + test.skip(!isMobile, "Phone layout."); + const chat = await createChat(request); + try { + await openChat(page, chat.id); + const trackersButton = page.locator('.mari-window-bubble[data-tracker-panel-toggle="bubble"]'); + const panel = page.locator('[data-component="TrackerDataSidebar"]:visible'); + // Phones begin at the button. + await expect(trackersButton).toBeVisible(); + await expect(panel).toHaveCount(0); + const trackersPoint = await center(trackersButton); + await trackersButton.click(); + await expect(panel).toBeVisible(); + await expect.poll(() => pressReaches(page, trackersPoint, ".mari-tracker-panel")).toBe(true); + await snap(page, info, "phone-tracker-panel-open"); + await panel.getByRole("button", { name: "Close tracker panel", exact: true }).click(); + await expect(trackersButton).toBeVisible(); + expect(await center(trackersButton)).toEqual(trackersPoint); + } finally { + await request.delete(`/api/chats/${chat.id}?force=true`); + } +}); diff --git a/e2e/message-trash-batching.e2e.ts b/e2e/message-trash-batching.e2e.ts index 4656d6d6bb..bab811e92d 100644 --- a/e2e/message-trash-batching.e2e.ts +++ b/e2e/message-trash-batching.e2e.ts @@ -11,7 +11,8 @@ for (const conflictsOnly of [false, true]) { ? "restore all keeps conflicts-only batches after a later failure" : "restore all batches large selections and keeps partial failures retryable", async ({ page, request }, testInfo) => { - test.setTimeout(90_000); + // Each 5,001-row render below can block slow CI WebKit for 5–17 s. + test.setTimeout(120_000); const created = await request.post("/api/chats", { data: { name: "Large recovery fixture", mode: "conversation" }, }); @@ -92,10 +93,11 @@ for (const conflictsOnly of [false, true]) { await panel.getByRole("tab", { name: "Trash", exact: true }).click(); // Avoid computing accessible names for every button in the 5,001-row mock. const restoreAll = panel.locator("button").filter({ hasText: /^Restore all$/ }); - // While the big list is on the page, wait for an element before asserting on it: every failed - // expect retry snapshots the whole page's accessibility tree for its message, which blocks the - // page for about 12 s in Chromium and longer in WebKit (the toast below then never arrives). - await restoreAll.waitFor({ timeout: 10_000 }); + // While the big list is on the page, wait for an element before asserting on it: every expect + // retry that finds no element snapshots the whole page's accessibility tree for its message, + // which blocks the page for about 12 s in Chromium and longer in WebKit. The first render of + // the 5,001 rows alone takes about 16 s on CI WebKit. + await restoreAll.waitFor({ timeout: 30_000 }); await expect(restoreAll).toBeEnabled(); if (conflictsOnly) { // Observe the real hook's result without exposing application internals in production. @@ -130,26 +132,53 @@ for (const conflictsOnly of [false, true]) { const initialMessageReads = messageReads; await restoreAll.click(); await expect.poll(() => batches.map((batch) => batch.length)).toEqual([5000, 1]); - await expect(restoreAll).toBeDisabled(); + // Disabling every row's buttons re-renders all 5,001 rows (about 8 s on CI WebKit). + await expect(restoreAll).toBeDisabled({ timeout: 30_000 }); await expect(panel.locator("button").filter({ hasText: /^Empty trash$/ })).toBeDisabled(); expect(trashReads).toBe(initialTrashReads); + // The toast's 6 s timer starts when it mounts, and re-rendering the 5,001 rows can then block + // slow WebKit for about as long, so the toast may be gone before a locator can see it. + // Record each toast as it mounts instead. + const toasts = await page.evaluateHandle(() => { + const mounted: { visible: boolean; type?: string; title?: string | null; description?: string | null }[] = []; + const seen = new WeakSet(); + new MutationObserver(() => { + for (const toast of document.querySelectorAll("[data-sonner-toast]")) { + if (seen.has(toast)) continue; + seen.add(toast); + const box = toast.getBoundingClientRect(); + mounted.push({ + // Playwright's definition of visible. + visible: box.width > 0 && box.height > 0 && getComputedStyle(toast).visibility !== "hidden", + type: toast.dataset.type, + title: toast.querySelector("[data-title]")?.textContent ?? null, + description: toast.querySelector("[data-description]")?.textContent ?? null, + }); + } + }).observe(document.body, { childList: true, subtree: true }); + return mounted; + }); releaseSecondBatch(); // Replacing 5,001 mocked rows takes longer under development rendering and browser tracing. - const failureToast = page.locator("[data-sonner-toast]").filter({ hasText: "Synthetic later-batch failure" }); - await failureToast.waitFor({ timeout: 30_000 }); - await expect(failureToast).toHaveAttribute("data-type", "warning"); + await expect + .poll(() => toasts.evaluate((mounted) => mounted), { timeout: 30_000 }) + .toContainEqual({ + visible: true, + type: "warning", + title: "Synthetic later-batch failure", + description: conflictsOnly ? null : "4999 messages restored", + }); if (conflictsOnly) { expect(await restoreObserver!.evaluate((observer) => observer.result)).toEqual({ restoredMessageIds: [], conflictEntryIds: batches[0], error: "Synthetic later-batch failure", }); - await expect(failureToast.locator("[data-description]")).toHaveCount(0); await expect(panel.locator("button").filter({ hasText: /^Restore$/ })).toHaveCount(5001); - await expect(restoreAll).toBeEnabled(); + // Re-enabling every row's buttons re-renders all 5,001 rows again. + await expect(restoreAll).toBeEnabled({ timeout: 30_000 }); expect(remaining.map((entry) => entry.id)).toEqual(batches.flat()); } else { - await expect(page.getByText("4999 messages restored", { exact: true })).toBeVisible(); await expect(panel.getByRole("button", { name: "Restore", exact: true })).toHaveCount(2); await expect.poll(() => messageReads).toBeGreaterThan(initialMessageReads); await expect(restoreAll).toBeEnabled(); diff --git a/e2e/phone-bubbles.e2e.ts b/e2e/phone-bubbles.e2e.ts index afafe33947..083dd55b10 100644 --- a/e2e/phone-bubbles.e2e.ts +++ b/e2e/phone-bubbles.e2e.ts @@ -753,7 +753,7 @@ test.describe("phone bubbles", () => { // Switching it on leaves the panel closed: it waits behind its bubble. await settings.locator('[data-window-control="close"]').click(); await expect(trackerBubble).toBeVisible(); - await expect(trackerBubble).toHaveAccessibleName("Open Trackers"); + await expect(trackerBubble).toHaveAccessibleName("Trackers"); await expect(panel).toHaveCount(0); await expectComposerClearAndNoSideScroll(page); diff --git a/e2e/tracker-window-routing.e2e.ts b/e2e/tracker-window-routing.e2e.ts index 813c17b382..8158e8c86e 100644 --- a/e2e/tracker-window-routing.e2e.ts +++ b/e2e/tracker-window-routing.e2e.ts @@ -159,7 +159,7 @@ test("Trackers reopens the selected panel and respects each chat's choice", asyn await panel.getByRole("button", { name: "Close tracker panel", exact: true }).click(); await expect(panel).toHaveCount(0); await expect(panelLauncher).toBeVisible(); - await expect(panelLauncher).toHaveAccessibleName("Open Trackers"); + await expect(panelLauncher).toHaveAccessibleName("Trackers"); await expect(classicLauncher).toHaveCount(0); await panelLauncher.click(); await expect(panel).toBeVisible(); diff --git a/packages/client/src/components/chat/TrackerPanelBubble.tsx b/packages/client/src/components/chat/TrackerPanelBubble.tsx index fef5edd911..8473b7993a 100644 --- a/packages/client/src/components/chat/TrackerPanelBubble.tsx +++ b/packages/client/src/components/chat/TrackerPanelBubble.tsx @@ -2,7 +2,7 @@ // Trackers button for chats using the Tracker Panel // // This replaces the standard tracker window's button while the panel is selected. -// Its place saves with the chat like every other bubble. +// It opens the panel or closes it, and its place saves with the chat like every other bubble. // ────────────────────────────────────────────── import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -17,6 +17,7 @@ import { } from "../../lib/floating-window-layout"; import { BUBBLE_SNAP_GAP_PX } from "../../lib/window-bubble-snap"; import { useMatchMedia } from "../../hooks/use-match-media"; +import { closeTrackerPanel } from "../../lib/tracker-panel-surface"; import { useUIStore } from "../../stores/ui.store"; import { FLOATING_WINDOW_Z_BASE, @@ -76,6 +77,8 @@ export function TrackerPanelBubble({ chatId, phoneSlot = 0 }: { chatId: string; onSizeChange={setSize} icon={} label={t("chat.trackerWindow.title")} + ariaLabel={t("chat.trackerWindow.title")} + tooltip={t("chat.trackerWindow.title")} expanded={open} zIndex={phoneLayout ? PHONE_BUBBLE_Z_INDEX : FLOATING_WINDOW_Z_BASE} attributes={{ @@ -87,9 +90,11 @@ export function TrackerPanelBubble({ chatId, phoneSlot = 0 }: { chatId: string; if (phoneLayout) state.savePhoneBubble(TRACKER_PANEL_BUBBLE_ID, point); else state.saveBubble(TRACKER_PANEL_BUBBLE_ID, point); }} - onOpen={(bubble) => - useFloatingWindowStore.getState().openWindow(TRACKER_PANEL_BUBBLE_ID, bubble, { focus: false }) - } + onOpen={(bubble) => { + // Like the Chat Settings button: a second press closes what the first one opened. + if (open) closeTrackerPanel(); + else useFloatingWindowStore.getState().openWindow(TRACKER_PANEL_BUBBLE_ID, bubble, { focus: false }); + }} /> ); } diff --git a/packages/client/src/components/layout/AppShell.tsx b/packages/client/src/components/layout/AppShell.tsx index fea05eb7e7..6dc4f9d92d 100644 --- a/packages/client/src/components/layout/AppShell.tsx +++ b/packages/client/src/components/layout/AppShell.tsx @@ -532,6 +532,7 @@ export function AppShell({ // width even when the viewport itself is desktop-sized. Switch that pane to the // compact chat layout before toolbar controls begin colliding. const mainRef = useRef(null); + const overlayTrackerPanelRef = useRef(null); const compactWidthRef = useRef(0); // width when we last switched to compact const centerCompact = useUIStore((s) => s.centerCompact); const setCenterCompact = useUIStore((s) => s.setCenterCompact); @@ -938,6 +939,13 @@ export function AppShell({ gameAssetsBrowserOpen || (shellOverlayMode && Boolean(mobileNavigationPanel))); + // The overlay Tracker Panel hides the chat control that opened it (globals.css), so keyboard focus moves into the panel. + useEffect(() => { + if (!shellOverlayMode || !trackerPanelVisible) return; + if (document.activeElement?.closest('[data-chat-covered="true"]')) { + overlayTrackerPanelRef.current?.focus({ preventScroll: true }); + } + }, [shellOverlayMode, trackerPanelVisible]); useEffect(() => { restoreTrackerPanelOpenForChat(activeChatId); }, [activeChatId, restoreTrackerPanelOpenForChat, trackerPanelEnabled]); @@ -1398,7 +1406,9 @@ export function AppShell({ + {/* Overlay mode keeps the chat mounted under panels and editors; globals.css hides its windows then. */}
{trackerPanelVisible && ( { + if (isSharpLoaderRejection(reason)) { + logger.warn( + reason as Error, + "[image] sharp's image runtime failed to start; image processing stays off until dependencies are reinstalled", + ); + return; + } logFatalProcessError(reason, "[process] Unhandled rejection; reaping sidecar before exit"); noteSessionExitKind("crash"); reapSidecar(); diff --git a/packages/server/src/services/image/sharp-runtime.ts b/packages/server/src/services/image/sharp-runtime.ts index 545df419c8..7d793297c2 100644 --- a/packages/server/src/services/image/sharp-runtime.ts +++ b/packages/server/src/services/image/sharp-runtime.ts @@ -41,3 +41,23 @@ export function clampByte(value: number): number { export function clampUnit(value: number): number { return Math.max(0, Math.min(1, value)); } + +/** Files of sharp's prebuilt loader packages (@img/sharp-, @img/sharp-wasm32). */ +const SHARP_LOADER_FILE = /[\\/]@img[\\/]sharp-[^\\/]+[\\/]/u; + +/** + * sharp's WebAssembly fallback finishes starting in an un-awaited async run(), so a + * failure there (for example a missing @emnapi/runtime) rejects a promise no caller + * holds. Image processing is optional, so that rejection must turn image processing + * off, not end the session; every other unhandled rejection stays fatal. + * ponytail: matches only sharp's own loader files; upgrade path: probe sharp in a child + * process before the in-process import, which also frees the ~44 MB half-started heap. + */ +export function isSharpLoaderRejection(reason: unknown): boolean { + if (!(reason instanceof Error)) return false; + const { requireStack } = reason as { requireStack?: unknown }; + const origin = Array.isArray(requireStack) + ? requireStack[0] + : reason.stack?.split("\n").find((line) => line.trimStart().startsWith("at ") && !/\b(?:node|wasm):/u.test(line)); + return typeof origin === "string" && SHARP_LOADER_FILE.test(origin); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e1f75fc4..d6cdc3f900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.93.0 version: 0.93.0(zod@3.25.76) + '@emnapi/runtime': + specifier: ^1.11.3 + version: 1.11.3 '@fastify/cors': specifier: ^11.3.0 version: 11.3.0 diff --git a/scripts/regressions/sharp-wasm-runtime-missing.regression.ts b/scripts/regressions/sharp-wasm-runtime-missing.regression.ts new file mode 100644 index 0000000000..8636eaf84b --- /dev/null +++ b/scripts/regressions/sharp-wasm-runtime-missing.regression.ts @@ -0,0 +1,58 @@ +// #7173: a Termux install updated in place from 2.4.6 kept @emnapi/runtime in pnpm's +// "skipped" list, so sharp's WebAssembly fallback could not finish starting. Its +// loader fails inside an un-awaited async run(), so besides the caught import error a +// second, detached rejection reached the server's unhandledRejection handler and shut +// the server down the first time a browser asked for a background thumbnail. +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const dataDir = mkdtempSync(join(tmpdir(), "marinara-sharp-wasm-")); +process.env.DATA_DIR = dataDir; +process.env.LOG_LEVEL = "silent"; + +try { + const { isSharpLoaderRejection } = await import("../../packages/server/src/services/image/sharp-runtime.js"); + const { resolveThumbPath } = await import("../../packages/server/src/services/image/image-thumbnail.js"); + + // Reproduce the stale install: Android has no native prebuild, so sharp takes the + // WebAssembly fallback, whose loader then cannot find @emnapi/runtime. + Object.defineProperty(process, "platform", { value: "android" }); + registerHooks({ + resolve: (specifier, context, nextResolve) => + nextResolve(specifier === "@emnapi/runtime" ? "@emnapi/runtime-missing-7173" : specifier, context), + }); + + const rejections: unknown[] = []; + process.on("unhandledRejection", (reason) => { + rejections.push(reason); + if (!isSharpLoaderRejection(reason)) { + console.error(reason); + process.exit(1); + } + }); + + const source = join(dataDir, "Black.jpg"); + writeFileSync(source, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + assert.equal(await resolveThumbPath(source, 320), null, "a broken sharp serves the original image"); + await new Promise((settle) => setTimeout(settle, 50)); + + assert.equal(rejections.length, 1, "sharp's WebAssembly loader leaves exactly one detached rejection"); + assert.equal((rejections[0] as NodeJS.ErrnoException).code, "MODULE_NOT_FOUND"); + assert.equal(isSharpLoaderRejection(rejections[0]), true, "sharp's loader failure is not fatal"); + assert.equal(isSharpLoaderRejection(new Error("engine bug")), false, "every other rejection stays fatal"); + assert.equal(isSharpLoaderRejection("not an error"), false); + + const indexSource = readFileSync(join(repositoryRoot, "packages/server/src/index.ts"), "utf8").replace(/\s+/gu, " "); + assert.ok( + /process\.on\("unhandledRejection", \(reason\) => \{ if \(isSharpLoaderRejection\(reason\)\) \{/u.test(indexSource), + "the server's unhandledRejection handler must let sharp's loader failure through before exiting", + ); + console.info("A missing sharp WebAssembly runtime serves original images and keeps the server running."); +} finally { + rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/regressions/termux-native-deps.regression.mjs b/scripts/regressions/termux-native-deps.regression.mjs new file mode 100644 index 0000000000..6b855d51f7 --- /dev/null +++ b/scripts/regressions/termux-native-deps.regression.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (path) => readFileSync(join(repositoryRoot, path), "utf8"); + +// #7178: pnpm 10 reads supportedArchitectures only from pnpm-workspace.yaml, package.json or the +// --os/--cpu/--libc flags. The .npmrc lines start-termux.sh used to append never applied, and +// Termux installs with pnpm's default target (android/), which is the one it needs. +for (const path of ["start-termux.sh", ".npmrc", "pnpm-workspace.yaml", "package.json"]) { + assert.doesNotMatch( + read(path), + /supportedArchitectures|TERMUX_FORCE_INSTALL/u, + `${path} must leave Termux installs on pnpm's default android/ target`, + ); +} + +// Every native loader Termux runs picks its binary by process.platform ("android"), so the +// default target must keep an android/arm64 build of each one. Linux builds are never loaded. +const lock = read("pnpm-lock.yaml"); +const [packagesSection, snapshotsSection] = lock.split("\npackages:\n")[1].split("\nsnapshots:\n"); +// Each entry starts at a two-space-indented key; blank lines between entries are optional in YAML. +const blocks = (section) => { + const entries = new Map(); + let key = null; + for (const line of section.split("\n")) { + if (/^ {2}\S/u.test(line)) { + key = line + .trim() + .replace(/:( \{\})?$/u, "") + .replace(/^'|'$/gu, ""); + entries.set(key, ""); + } else if (key && line.trim()) entries.set(key, `${entries.get(key)}${line}\n`); + } + return entries; +}; +const packages = blocks(packagesSection); +const snapshots = blocks(snapshotsSection); +// Compare whole array values: "!android" or "!arm64" excludes the target instead of naming it. +const listField = (metadata, field) => + (new RegExp(`^ {4}${field}: \\[([^\\]]*)\\]`, "mu").exec(metadata)?.[1] ?? "") + .split(",") + .map((value) => value.trim().replace(/^'|'$/gu, "")) + .filter(Boolean); +const allows = (values, target) => values.includes(target) && !values.includes(`!${target}`); +const runsOnAndroidArm64 = (key) => { + const metadata = packages.get(key) ?? ""; + return allows(listField(metadata, "os"), "android") && allows(listField(metadata, "cpu"), "arm64"); +}; + +for (const loader of ["esbuild", "rollup", "lightningcss", "@tailwindcss/oxide", "@napi-rs/canvas"]) { + const versions = [...snapshots].filter(([key]) => key.startsWith(`${loader}@`)); + assert.ok(versions.length > 0, `${loader} must be in the lockfile`); + for (const [key, body] of versions) { + const optional = body.split("optionalDependencies:\n")[1] ?? ""; + const androidBuild = [...optional.matchAll(/^ {6}'?([^':\s]+)'?: (\S+)/gmu)] + .map(([, name, version]) => `${name}@${version.replace(/\(.*$/u, "")}`) + .find(runsOnAndroidArm64); + assert.ok(androidBuild, `${key} needs an android/arm64 optional build for Termux installs`); + } +} + +console.info("Termux installs use pnpm's Android target, and every native loader has an android/arm64 build."); diff --git a/scripts/regressions/termux-sharp.regression.mjs b/scripts/regressions/termux-sharp.regression.mjs index b6775bdbd5..13507d0b44 100644 --- a/scripts/regressions/termux-sharp.regression.mjs +++ b/scripts/regressions/termux-sharp.regression.mjs @@ -15,6 +15,29 @@ const lock = readFileSync(join(repositoryRoot, "pnpm-lock.yaml"), "utf8"); const serverLock = lock.split("\n packages/server:\n")[1]?.split("\n packages/shared:\n")[0]; const sharpVersion = serverLock?.match(/\n sharp:\n specifier: [^\n]+\n version: ([^\n]+)/u)?.[1]; assert(sharpVersion); +// #7173: pnpm keeps a package it once skipped in node_modules/.modules.yaml. An in-place +// frozen update (start-termux.sh) reaches @emnapi/runtime first through an always-skipped +// parent (@huggingface/transformers -> sharp -> @img/sharp-freebsd-wasm32) and never +// un-skips it, so the WASM fallback starts without it. A direct server dependency is +// reached from an installable parent, which installs it on fresh and in-place updates. +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +const emnapiVersion = lock + .split("\nsnapshots:\n")[1] + ?.match( + new RegExp( + `\\n '@img/sharp-wasm32@${escapeRegExp(wasmVersion)}':\\n dependencies:\\n '@emnapi/runtime': (\\S+)`, + "u", + ), + )?.[1]; +assert(emnapiVersion, "the WASM fallback's @emnapi/runtime snapshot must be readable"); +const serverRuntimeDependencies = serverLock?.split(" dependencies:\n")[1]?.split(/\n [A-Za-z]+:\n/u)[0] ?? ""; +assert.ok( + new RegExp( + `\\n '@emnapi/runtime':\\n specifier: \\S+\\n version: ${escapeRegExp(emnapiVersion)}(?:\\n|$)`, + "u", + ).test(`\n${serverRuntimeDependencies}`), + `the server must depend on @emnapi/runtime ${emnapiVersion} directly so in-place updates install it`, +); const runner = resolvePnpmRunner(); const pnpm = (args, cwd) => { const result = spawnSync(runner.command, [...runner.args, ...args], { @@ -74,6 +97,8 @@ ${lock.slice(lock.indexOf("\npackages:\n"))}`, "--input-type=module", "-e", ` + // sharp's WASM loader finishes starting in an un-awaited async run(); its failures surface only here. + process.on("unhandledRejection", (reason) => { console.error(reason); process.exit(3); }); import assert from "node:assert/strict"; import { createRequire } from "node:module"; const require = createRequire(process.cwd() + "/package.json"); diff --git a/start-termux.sh b/start-termux.sh index b3f1f558df..c018820e1a 100755 --- a/start-termux.sh +++ b/start-termux.sh @@ -96,35 +96,6 @@ for pkg_name in git; do fi done -# ── Fix platform detection for native binaries ── -# Node.js 24+ on Termux reports process.platform = "android", but Termux uses -# the Linux kernel and Linux ARM64 native binaries work perfectly. Tell pnpm to -# install both android AND linux optional dependencies so build tools like -# rollup, lightningcss, and tailwindcss oxide resolve correctly. -# Run early so the auto-update's pnpm install also benefits. -NODE_PLAT=$(node -e "process.stdout.write(process.platform)" 2>/dev/null || echo "") -if [ "$NODE_PLAT" = "android" ]; then - NPMRC_MARKER="# termux-supported-architectures" - if ! grep -q "$NPMRC_MARKER" .npmrc 2>/dev/null; then - NODE_ARCH=$(node -e "process.stdout.write(process.arch)" 2>/dev/null || echo "") - echo " [OK] Detected Android/Termux (${NODE_ARCH:-unknown}) — enabling Linux binaries" - { - echo "$NPMRC_MARKER" - echo "supportedArchitectures.os[]=current" - echo "supportedArchitectures.os[]=linux" - echo "supportedArchitectures.cpu[]=current" - [ -n "$NODE_ARCH" ] && echo "supportedArchitectures.cpu[]=$NODE_ARCH" - } >> .npmrc - # Force pnpm to re-resolve optional deps on next install - TERMUX_FORCE_INSTALL=1 - fi - # Ensure wasm32 is supported (required for sharp fallback on some Android devices) - if ! grep -q "supportedArchitectures.cpu\[\]=wasm32" .npmrc 2>/dev/null; then - echo "supportedArchitectures.cpu[]=wasm32" >> .npmrc - TERMUX_FORCE_INSTALL=1 - fi -fi - # ── Check Node.js ── if ! command -v node &> /dev/null || ! node -v &> /dev/null; then echo " [..] Node.js not found or broken — installing via pkg..." @@ -310,6 +281,8 @@ prune_pnpm_store() { install_workspace_dependencies() { # Avoid --force here. On constrained Android devices it recreates the entire # virtual store and may download optional binaries for platforms we cannot run. + # pnpm's default target (android/) is the right one: the native build + # tools ship Android builds, and nothing loads Linux binaries on Android. # Termux provides a global libvips but no Android NDK; Sharp must use its # supported WebAssembly fallback rather than attempting a native source build. SHARP_IGNORE_GLOBAL_LIBVIPS=1 run_pnpm install --frozen-lockfile --prefer-offline @@ -581,9 +554,9 @@ if [ -f "packages/shared/dist/constants/defaults.js" ]; then fi # ── Install dependencies ── -if [ ! -d "node_modules" ] || [ "$TERMUX_FORCE_INSTALL" = "1" ] || ! node scripts/check-workspace-install.mjs >/dev/null 2>&1; then +if [ ! -d "node_modules" ] || ! node scripts/check-workspace-install.mjs >/dev/null 2>&1; then echo "" - echo " [..] Installing dependencies${TERMUX_FORCE_INSTALL:+ (refreshing for platform fix)}..." + echo " [..] Installing dependencies..." echo " This may take several minutes on mobile." echo "" prune_pnpm_store