diff --git a/AGENTS.md b/AGENTS.md index a258d4a68..e2ee3ff4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,9 @@ - OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. - Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. - Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. +- Composer context selectors and actions always share one row on desktop and touch; truncate selector labels rather than wrapping the footer. Dense conversation toolbar/footer controls use `--touch-target-size-compact`; timeline markers use the compact control height rather than the global touch target. Toolbar touch spacing lives in `styles/panels/session-toolbar-touch.css`. Transcript filters join the header action overflow and anchor/focus back to that menu when collapsed. +- Composer height limits use the same visible conversation height: minimum 8% (44 px floor), maximum 60% (never below that minimum). Width and draft length do not change the limits. `prompt-input/usePromptViewport.ts` observes the conversation and its intersection with `visualViewport`, including keyboard resize/pan. Store manually chosen heights as proportions so the field itself follows viewport changes; migrate legacy pixel choices on the first valid measurement. Viewport-driven changes never overwrite the saved proportion; pointer and keyboard resize controls share the same limits and long drafts remain scrollable. +- Browser preview retains its six original fixed templates (responsive, desktop, tablet portrait/landscape, mobile portrait/landscape), without a separate rotation button. Mobile templates automatically use native Android/Chromium touch emulation; iframe fallback changes dimensions only. Profiles share `packages/ui/src/lib/native/browser-emulation.json`; only owned native guests may receive device overrides, and returning to dimensions clears them before reloading. Main local/remote desktop windows can shrink to 390 logical pixels; native restore must preserve that width. - Composer attachments use one native-device action and project-scoped `@` references. Picker/drop/clipboard bytes share `prompt-input/useDeviceAttachments.ts` for serialized budgets and epoch-based draft/focus fencing; keep lifecycle and read logic out of `prompt-input.tsx`. File listing/search validate the session directory through existing workspace ownership before reading a worktree or translated WSL root. - Session rows keep actions inline until their measured title, badges, and controls no longer fit. Keep responsive action styles in `styles/components/session-row-actions.css`; hidden inline controls remain measurable but inert, and an open overflow menu stays mounted until dismissal. - Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts. @@ -23,7 +26,7 @@ - Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius. - The message-content popup and Chat settings share `components/transcript-visibility.ts` and the semantic icons from `components/message-content-icons.ts`; tool presentation metadata lives independently of renderers in `components/tool-call/tool-presentation.ts`. Popup styles live in `styles/components/transcript-filters.css`. - Tool-result images retain native `state.content` and render through the shared `components/tool-call/output-images.tsx` surface, including MCP and specialized tools. Keep image bytes out of text projections (copy/search/speech); image styles live in `styles/messaging/tool-call/images.css`. -- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css` and preserve compact-layout hiding. +- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css`. Hide the rail below 420 CSS px of the `session-center` container on all platforms. The composer worktree selector becomes a compact arrow-only cell below 460 CSS px of that conversation container (not the remaining composer width); keep its accessible label and styles in `styles/components/prompt-context-controls.css`. Visibility must not depend on measured header density, changing status controls or viewport height; preserve the saved visibility preference. - Timeline geometry uses the cached structural index independently of transcript/excerpt loading. Aggregate tool markers retain the outline's representative tool name so their icon does not fall back to `other`; preserve that metadata through projection and persisted-index revalidation. Hover/focus previews render bounded Markdown, prefetch visible-nearby excerpts and prioritize the hovered marker through `stores/timeline-previews.ts`; never mount whole message/tool cards or fetch transcript windows for hover. Keep the viewport-bounded surface in `styles/messaging/timeline-preview.css`. - Document any new styling conventions or directory additions in this file so future changes remain consistent. - Soft palette families live in `packages/ui/src/lib/soft-color-schemes.ts`, with references in `dev-docs/PALETTE_SOURCES.md`. Keep selection independent of participant identity, and keep transcript/composer surfaces distinct. Run `palette-quality.test.ts` and inspect real rendered captures when changing palette colors or their token mapping. diff --git a/dev-docs/BROWSER_DEVICE_EMULATION.md b/dev-docs/BROWSER_DEVICE_EMULATION.md new file mode 100644 index 000000000..8bcb4c48b --- /dev/null +++ b/dev-docs/BROWSER_DEVICE_EMULATION.md @@ -0,0 +1,46 @@ +# Browser preview device emulation + +The viewport menu retains its original six fixed templates: responsive, +desktop 1440 × 900, tablet 768 × 1024, tablet landscape 1024 × 768, +mobile 390 × 844 and mobile landscape 844 × 390 CSS px. There is no separate +rotation button or size catalogue. Native mobile templates automatically apply +Android / Chromium touch, mobile identity and density settings. +Device profiles set CSS viewport, screen orientation, DPR 2.75, touch media and +events, Android user agent and mobile client hints. Switching between profiles +reloads the preview because applications can cache device detection at startup. +Returning to any dimension preset clears all overrides and reloads again. + +Electron and Windows Tauri apply overrides to the owned native preview only. +The sandboxed iframe fallback changes dimensions only, with a translated +tooltip explaining that emulation requires the native app. Neither application privileges nor a +general CDP bridge are exposed to preview pages. Tauri's synchronous WebView2 +callbacks are awaited off the UI thread. Electron retains its debugger attachment +while emulation is active so accessibility automation does not reset overrides. + +Canonical profile data lives in `packages/ui/src/lib/native/browser-emulation.json`. +Native adapters use the installed Chromium version for the simulated user agent. +These generic profiles do not emulate Gecko/Waterfox, Safari, mobile OS text settings, +keyboard occlusion or browser/system bars. They are a reproducible Android/Chromium +layout reference, not a pixel-identical representation of every phone. + +The composer keeps selectors and actions on one row at every width, truncating +labels rather than wrapping. Dense toolbar/footer touch targets use 32 px; timeline +markers use 24 px height. Below 460 CSS px of conversation width, the worktree +selector becomes a 32 px arrow-only cell while retaining its accessible label. +The timeline is hidden below 420 CSS px of conversation +width on every platform, independently of header density, status controls or +viewport height. The saved visibility preference is preserved. Header filters join the other actions in the overflow +menu when space runs out. Main local and remote desktop windows can shrink to +390 logical pixels and restore at that width. Tests cover 360, 390 and 430 px widths +plus 320 px, landscape, mouse/touch media, draft preservation, native profile/reset +behavior and the real viewport menu's native/fallback boundaries. + +Composer sizing also follows the visible conversation height (including the +visual viewport when a software keyboard opens). The minimum is 8% of that +height, with a 44 px floor. Width and draft length do not change the limits. +Long drafts scroll inside the field. Manual sizing is saved as a proportion +of the available height, bounded to 8–60%: the field itself scales when the +window or visual viewport changes. The 44 px floor never overwrites that +proportion. Legacy pixel heights convert on the first valid measurement. +Pointer and keyboard +resizing share the same minimum and maximum. diff --git a/packages/electron-app/electron/main/browser-controller.test.ts b/packages/electron-app/electron/main/browser-controller.test.ts index 4de3a0fb5..a23a0ec4f 100644 --- a/packages/electron-app/electron/main/browser-controller.test.ts +++ b/packages/electron-app/electron/main/browser-controller.test.ts @@ -25,6 +25,7 @@ function createGuest(options: GuestOptions) { isDestroyed: () => false, isLoading: () => options.loading ?? false, stop: () => undefined, + reload: () => undefined, getURL: () => url, loadURL: async (nextUrl: string) => { await options.loadURL?.(nextUrl) @@ -57,6 +58,70 @@ function createHarness(requestOpen: (sessionID: string, url: string, requestID: } describe("BrowserController", () => { + it("preserves mobile overrides when automation times out and fences late protocol work", async () => { + const harness = createHarness() + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const methods: string[] = [] + let stall = true + const { guest, counts } = createGuest({ id: 96, owner: harness.owner, sendCommand: async method => { + methods.push(method) + if (method === "Browser.getVersion") return { product: "Chrome/140.0.0.0" } + if (method === "Accessibility.enable" && stall) await gate + return { nodes: [] } + } }) + harness.add(guest, "mobile-timeout") + await harness.controller.emulate(harness.owner, "mobile-timeout", "mobile") + await assert.rejects(harness.controller.execute("session", { action: "snapshot" }, Date.now() + 30), /timed out/) + assert.equal(guest.debugger.isAttached(), true, "a timeout must not clear persistent mobile overrides") + assert.deepEqual(counts(), { attachCount: 1, detachCount: 0 }) + release() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(methods.includes("Accessibility.getFullAXTree"), false, "expired continuations must not send protocol commands") + stall = false + await harness.controller.execute("session", { action: "snapshot" }) + assert.equal(guest.debugger.isAttached(), true) + await harness.controller.emulate(harness.owner, "mobile-timeout", "none") + assert.deepEqual(counts(), { attachCount: 1, detachCount: 1 }) + }) + + it("reapplies the same mobile profile after an external debugger detach", async () => { + const harness = createHarness() + let applications = 0 + const { guest } = createGuest({ id: 97, owner: harness.owner, sendCommand: async method => { + if (method === "Browser.getVersion") return { product: "Chrome/140.0.0.0" } + if (method === "Emulation.setDeviceMetricsOverride") applications++ + return {} + } }) + harness.add(guest, "mobile-detach") + await harness.controller.emulate(harness.owner, "mobile-detach", "mobile") + guest.debugger.detach() + await harness.controller.emulate(harness.owner, "mobile-detach", "mobile") + assert.equal(applications, 2) + assert.equal(guest.debugger.isAttached(), true) + }) + + it("rejects emulation from another owner and fences queued target replacement", async () => { + const harness = createHarness() + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + let entered!: () => void + const started = new Promise(resolve => { entered = resolve }) + const { guest } = createGuest({ id: 95, owner: harness.owner, sendCommand: async method => { + if (method === "Accessibility.getFullAXTree") { entered(); await gate; return { nodes: [] } } + return {} + } }) + harness.add(guest, "emulation") + await assert.rejects(harness.controller.emulate({ id: 2 } as WebContents, "emulation", "mobile"), /does not belong/) + const snapshot = harness.controller.execute("session", { action: "snapshot" }) + await started + const emulation = harness.controller.emulate(harness.owner, "emulation", "mobile") + harness.controller.unregister(harness.owner, "emulation") + release() + await snapshot + await assert.rejects(emulation, /target changed/) + }) + it("cleans registrations after native window destruction without reading its getters", () => { const harness = createHarness() let destroyed = false diff --git a/packages/electron-app/electron/main/browser-controller.ts b/packages/electron-app/electron/main/browser-controller.ts index 1b6b0e415..d07c7b980 100644 --- a/packages/electron-app/electron/main/browser-controller.ts +++ b/packages/electron-app/electron/main/browser-controller.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module" import { randomUUID } from "node:crypto" import type { BrowserWindow, WebContents } from "electron" import { isBrowserUrlAllowed } from "./browser-webview-security" +import { setBrowserEmulation } from "./browser-emulation" const electron = createRequire(import.meta.url)("electron") as typeof import("electron") @@ -106,6 +107,17 @@ export class BrowserController { this.registrations.set(input.registrationId, { ...input, owner, guest }) } + async emulate(owner: WebContents, registrationId: unknown, preset: unknown): Promise { + const registration = typeof registrationId === "string" ? this.registrations.get(registrationId) : undefined + if (!registration || registration.owner !== owner) throw new Error("Browser target does not belong to this window") + await this.enqueue(registration.guest, async () => { + if (this.registrations.get(registration.registrationId) !== registration || owner.isDestroyed() || registration.guest.isDestroyed()) { + throw new Error("Browser target changed before emulation") + } + await setBrowserEmulation(registration.guest, preset) + }) + } + unregister(owner: WebContents, registrationId: unknown): void { if (typeof registrationId !== "string") return const registration = this.registrations.get(registrationId) @@ -344,12 +356,20 @@ function stringValue(value: unknown): string { return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, 500) : "" } -async function withDebugger(guest: WebContents, deadline: number, operation: (debuggerSession: WebContents["debugger"]) => Promise): Promise { +async function withDebugger(guest: WebContents, deadline: number, operation: (debuggerSession: Pick) => Promise): Promise { const attached = guest.debugger.isAttached() if (!attached) guest.debugger.attach("1.3") + const debuggerSession = { + sendCommand: (...args: Parameters) => { + // A pending protocol reply can outlive the request deadline. Fence its + // continuation without detaching someone else's persistent overrides. + ensureDeadline(deadline) + return guest.debugger.sendCommand(...args) + }, + } try { - return await withDeadline(() => operation(guest.debugger), deadline, () => { - if (guest.debugger.isAttached()) guest.debugger.detach() + return await withDeadline(() => operation(debuggerSession), deadline, () => { + if (!attached && guest.debugger.isAttached()) guest.debugger.detach() }) } finally { if (!attached && guest.debugger.isAttached()) guest.debugger.detach() diff --git a/packages/electron-app/electron/main/browser-emulation.ts b/packages/electron-app/electron/main/browser-emulation.ts new file mode 100644 index 000000000..8b4a91ea9 --- /dev/null +++ b/packages/electron-app/electron/main/browser-emulation.ts @@ -0,0 +1,55 @@ +import type { WebContents } from "electron" +import profiles from "../../../ui/src/lib/native/browser-emulation.json" +import { browserEmulationProfile } from "../../../ui/src/lib/native/browser-emulation" + +// The debugger must remain attached while overrides are active. Automation's +// withDebugger preserves an existing attachment, so snapshots cannot reset it. +const ownedAttachments = new WeakSet() +const currentProfiles = new WeakMap() + +export async function setBrowserEmulation(guest: WebContents, preset: unknown): Promise { + browserEmulationProfile(preset) + const next = preset as string + const previous = currentProfiles.get(guest) ?? "none" + if (previous === next && (next === "none" || guest.debugger.isAttached())) return + if (!guest.debugger.isAttached()) { + guest.debugger.attach("1.3") + ownedAttachments.add(guest) + } + const apply = async (id: string) => { + const send = (method: string, params: object) => guest.debugger.sendCommand(method, params) + if (id === "none") { + await send("Emulation.clearDeviceMetricsOverride", {}) + await send("Emulation.setTouchEmulationEnabled", { enabled: false }) + await send("Emulation.setEmitTouchEventsForMouse", { enabled: false }) + await send("Emulation.setUserAgentOverride", { userAgent: "" }) + return + } + const profile = browserEmulationProfile(id)! + const version = await guest.debugger.sendCommand("Browser.getVersion") as { product: string } + const fullVersion = version.product.split("/").pop()! + await send("Emulation.setDeviceMetricsOverride", { + width: profile.width, height: profile.height, deviceScaleFactor: profile.deviceScaleFactor, + mobile: true, screenWidth: profile.width, screenHeight: profile.height, + screenOrientation: { type: profile.orientation, angle: profile.angle }, + }) + await send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 }) + await send("Emulation.setEmitTouchEventsForMouse", { enabled: true, configuration: "mobile" }) + await send("Emulation.setUserAgentOverride", { + userAgent: profiles.userAgent.replace("{version}", fullVersion), platform: "Linux armv8l", + userAgentMetadata: { brands: [{ brand: "Chromium", version: fullVersion.split(".")[0] }], + fullVersion, platform: "Android", platformVersion: "11.0.0", architecture: "", model: "", mobile: true }, + }) + } + try { + await apply(next) + currentProfiles.set(guest, next) + // Applications often cache UA detection at startup; apply before reloading. + guest.reload() + } catch (error) { + await apply(previous) + throw error + } finally { + if ((currentProfiles.get(guest) ?? "none") === "none" && ownedAttachments.delete(guest) && guest.debugger.isAttached()) guest.debugger.detach() + } +} diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index c8d3482d5..29f9745c7 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -114,6 +114,10 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD }) return { ok: true } }) + ipcMain.handle("browser-target:emulate", async (event, registrationId: unknown, preset: unknown) => { + const { window } = local(event) + await dependencies.browserController.emulate(window.webContents, registrationId, preset) + }) ipcMain.handle("browser-target:claimOpen", async (event, requestID: unknown) => { const window = local(event).window const claimed = dependencies.browserController.claimOpen(window.webContents, requestID) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 02b36d0fc..a80cb2642 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -26,7 +26,7 @@ import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-regi import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { SerializedLifecycle } from "./serialized-lifecycle" import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" -import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" +import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MIN_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" const mainDirname = dirname(fileURLToPath(import.meta.url)) @@ -225,7 +225,7 @@ function runPrimary(firstIntent: LaunchIntent) { const bounds = saved ? clampWindowBounds(saved.bounds, screen.getAllDisplays().map((display) => ({ ...display.workArea, scaleFactor: display.scaleFactor }))) : undefined const window = new BrowserWindow({ width: bounds?.width ?? DEFAULT_WINDOW_WIDTH, height: bounds?.height ?? DEFAULT_WINDOW_HEIGHT, - ...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: 800, minHeight: 600, + ...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: MIN_WINDOW_WIDTH, minHeight: 600, frame: false, autoHideMenuBar: true, backgroundColor: "#1a1a1a", icon: getIconPath(), webPreferences: { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, webviewTag: true, @@ -438,7 +438,7 @@ function runPrimary(firstIntent: LaunchIntent) { } const remoteSession = session.fromPartition(resolveRemoteSessionPartition(payload.id, payload.proxySessionId)) const window = new BrowserWindow({ - width: 1400, height: 900, minWidth: 800, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, + width: 1400, height: 900, minWidth: MIN_WINDOW_WIDTH, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, webPreferences: { session: remoteSession, preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=remote"] }, }) const nativeWindowId = window.id diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts index cf2d90e78..e1ee23967 100644 --- a/packages/electron-app/electron/main/window-state.test.ts +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -54,6 +54,10 @@ test("move/resize bursts debounce and final flush preserves pre-maximize bounds" }) test("normalizes persisted window state", () => { + for (const width of [320, 390, 430]) { + assert.deepEqual(clampWindowBounds({ x: 20, y: 30, width, height: 844 }, [primaryDisplay]), + { x: 20, y: 30, width: Math.max(390, width), height: 844 }) + } assert.equal(normalizeNativeWindowState({ bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, maximized: false, fullscreen: false, zoomFactor: 1 }), undefined) assert.deepEqual(clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), { x: 520, y: 180, width: 1400, height: 900 }) assert.deepEqual( @@ -69,7 +73,7 @@ test("mixed-DPI monitor selection compares intersections in physical coordinates { x: 0, y: 0, width: 1920, height: 1080, scaleFactor: 1 }, { x: 960, y: 0, width: 1280, height: 720, scaleFactor: 2 }, ], - ), { x: 1000, y: 120, width: 800, height: 600 }) + ), { x: 1000, y: 120, width: 700, height: 600 }) }) test("normalizes unsafe zoom factors", () => { diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts index 1f7319500..f4cab6823 100644 --- a/packages/electron-app/electron/main/window-state.ts +++ b/packages/electron-app/electron/main/window-state.ts @@ -18,7 +18,7 @@ export interface NativeWindowState { export const DEFAULT_WINDOW_WIDTH = 1400 export const DEFAULT_WINDOW_HEIGHT = 900 -const MIN_WINDOW_WIDTH = 800 +export const MIN_WINDOW_WIDTH = 390 const MIN_WINDOW_HEIGHT = 600 const MIN_ZOOM_FACTOR = 0.25 const MAX_ZOOM_FACTOR = 5 diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 04ec71086..837f66bad 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -72,6 +72,7 @@ const localElectronAPI = { ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), registerBrowserTarget: (payload) => ipcRenderer.invoke("browser-target:register", payload), + emulateBrowserTarget: (registrationId, preset) => ipcRenderer.invoke("browser-target:emulate", registrationId, preset), unregisterBrowserTarget: (registrationId) => ipcRenderer.invoke("browser-target:unregister", registrationId), claimBrowserOpen: (requestId) => ipcRenderer.invoke("browser-target:claimOpen", requestId), releaseBrowserOpen: (requestId) => ipcRenderer.invoke("browser-target:releaseOpen", requestId), diff --git a/packages/tauri-app/src-tauri/src/browser_controller.rs b/packages/tauri-app/src-tauri/src/browser_controller.rs index 460c41831..63b51ddc5 100644 --- a/packages/tauri-app/src-tauri/src/browser_controller.rs +++ b/packages/tauri-app/src-tauri/src/browser_controller.rs @@ -8,6 +8,10 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::webview::PageLoadEvent; use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Window}; +#[cfg(windows)] +#[path = "browser_emulation.rs"] +mod emulation; + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BrowserTargetRegistration { @@ -39,6 +43,7 @@ pub(crate) struct BrowserTargetAction { registration_id: String, action: String, url: Option, + preset: Option, } #[derive(Clone)] @@ -69,6 +74,7 @@ struct OpenClaim { #[derive(Default)] struct Inner { + emulation_profiles: HashMap, registrations: HashMap, renderer_versions: HashMap, refs: HashMap>, @@ -80,6 +86,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct BrowserController { + emulation_lock: Arc>, inner: Arc<(Mutex, Condvar)>, profile: Arc, registration_sequence: Arc, @@ -88,6 +95,7 @@ pub(crate) struct BrowserController { impl BrowserController { pub(crate) fn new(profile: std::path::PathBuf) -> Self { Self { + emulation_lock: Arc::new(Mutex::new(())), inner: Arc::new((Mutex::new(Inner::default()), Condvar::new())), profile: Arc::new(profile), registration_sequence: Arc::new(AtomicU64::new(1)), @@ -296,6 +304,7 @@ impl BrowserController { .get_webview(®istration.webview_label) .ok_or_else(|| "Browser preview is no longer available".to_string())?; match input.action.as_str() { + "emulate" => self.emulate(app, window_label, &input), "back" => webview .eval("history.back()") .map_err(|error| error.to_string()), @@ -334,6 +343,7 @@ impl BrowserController { return Err("Browser target belongs to another window".to_string()); } let registration = inner.registrations.remove(registration_id).unwrap(); + inner.emulation_profiles.remove(registration_id); inner.refs.remove(registration_id); inner.navigation_versions.remove(registration_id); inner.page_load_expectations.remove(registration_id); diff --git a/packages/tauri-app/src-tauri/src/browser_emulation.rs b/packages/tauri-app/src-tauri/src/browser_emulation.rs new file mode 100644 index 000000000..7f144676e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/browser_emulation.rs @@ -0,0 +1,114 @@ +use super::*; + +impl BrowserController { + pub(super) fn emulate( + &self, + app: &AppHandle, + owner: &str, + input: &BrowserTargetAction, + ) -> Result<(), String> { + let preset = input.preset.as_deref().unwrap_or(""); + profile(preset)?; + // Never hold the controller state mutex while waiting for WebView2. + let _admission = self + .emulation_lock + .try_lock() + .map_err(|_| "Browser emulation is already changing".to_string())?; + let registration = self.owned_registration(owner, &input.registration_id)?; + let webview = app + .get_webview(®istration.webview_label) + .ok_or_else(|| "Browser preview is no longer available".to_string())?; + let previous = self + .inner + .0 + .lock() + .map_err(|e| e.to_string())? + .emulation_profiles + .get(&input.registration_id) + .cloned() + .unwrap_or_else(|| "none".into()); + if previous == preset { + return Ok(()); + } + if let Err(error) = apply(&webview, preset) { + apply(&webview, &previous)?; + return Err(error); + } + let current = self.owned_registration(owner, &input.registration_id)?; + if current.generation != registration.generation { + return Err("Browser target changed before emulation".into()); + } + self.inner + .0 + .lock() + .map_err(|e| e.to_string())? + .emulation_profiles + .insert(input.registration_id.clone(), preset.into()); + webview.reload().map_err(|e| e.to_string()) + } +} + +fn apply(webview: &tauri::Webview, preset: &str) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(20); + let send = |method, params| cdp(webview, method, params, deadline).map(|_| ()); + if preset == "none" { + send("Emulation.clearDeviceMetricsOverride", json!({}))?; + send( + "Emulation.setTouchEmulationEnabled", + json!({ "enabled": false }), + )?; + send( + "Emulation.setEmitTouchEventsForMouse", + json!({ "enabled": false }), + )?; + return send("Emulation.setUserAgentOverride", json!({ "userAgent": "" })); + } + let profiles: Value = serde_json::from_str(include_str!( + "../../../ui/src/lib/native/browser-emulation.json" + )) + .map_err(|e| e.to_string())?; + let (size, landscape) = profile(preset)?.unwrap(); + let dimensions = &profiles[size]; + let width = &dimensions[if landscape { "height" } else { "width" }]; + let height = &dimensions[if landscape { "width" } else { "height" }]; + let version = cdp(webview, "Browser.getVersion", json!({}), deadline)?; + let full_version = version["product"] + .as_str() + .and_then(|v| v.rsplit('/').next()) + .ok_or_else(|| "Chromium version is unavailable".to_string())?; + send( + "Emulation.setDeviceMetricsOverride", + json!({ + "width": width, "height": height, "deviceScaleFactor": profiles["deviceScaleFactor"], + "mobile": true, "screenWidth": width, "screenHeight": height, + "screenOrientation": { "type": if landscape { "landscapePrimary" } else { "portraitPrimary" }, "angle": if landscape { 90 } else { 0 } } + }), + )?; + send( + "Emulation.setTouchEmulationEnabled", + json!({ "enabled": true, "maxTouchPoints": 5 }), + )?; + send( + "Emulation.setEmitTouchEventsForMouse", + json!({ "enabled": true, "configuration": "mobile" }), + )?; + send( + "Emulation.setUserAgentOverride", + json!({ + "userAgent": profiles["userAgent"].as_str().unwrap().replace("{version}", full_version), + "platform": "Linux armv8l", + "userAgentMetadata": { "brands": [{ "brand": "Chromium", "version": full_version.split('.').next().unwrap() }], + "fullVersion": full_version, "platform": "Android", "platformVersion": "11.0.0", + "architecture": "", "model": "", "mobile": true } + }), + ) +} + +fn profile(preset: &str) -> Result, String> { + if preset == "none" { return Ok(None); } + let size = preset.strip_suffix("Landscape").unwrap_or(preset); + if size != "mobile" { + return Err("Invalid browser emulation profile".into()); + } + Ok(Some((size, size != preset))) +} diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index 272c09fac..136528624 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -27,7 +27,7 @@ pub(crate) use navigation::{ }; pub use window::{ capture_and_flush_all_windows, capture_and_flush_window, local_window_zoom, - set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, + set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, MIN_WINDOW_WIDTH, }; use envelope::PersistedClientState; diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index 0363305b9..7497dc18c 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -556,6 +556,12 @@ fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { } #[test] fn normalizes_window_bounds_against_displays() { + for width in [320, 390, 430] { + assert_eq!( + clamp_window_bounds(&bounds(20, 30, width, 844), &[display(0, 0, 1920, 1080)]), + Some(bounds(20, 30, width.max(390), 844)) + ); + } let cases = [ ( bounds(4000, 2000, 1400, 900), @@ -581,7 +587,7 @@ fn mixed_dpi_restore_selects_displays_in_physical_coordinates() { &bounds(1000, 500, 700, 600), &[display(0, 0, 1920, 1080), high_dpi], ), - Some(bounds(1000, 120, 800, 600)) + Some(bounds(1000, 120, 700, 600)) ); } #[test] diff --git a/packages/tauri-app/src-tauri/src/client_state/window.rs b/packages/tauri-app/src-tauri/src/client_state/window.rs index a64fdb7b7..2f6fc4e96 100644 --- a/packages/tauri-app/src-tauri/src/client_state/window.rs +++ b/packages/tauri-app/src-tauri/src/client_state/window.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WindowEvent}; -const MIN_WINDOW_WIDTH: i32 = 800; +pub const MIN_WINDOW_WIDTH: i32 = 390; const MIN_WINDOW_HEIGHT: i32 = 600; const MIN_ZOOM_LEVEL: f64 = 0.25; pub(super) const MAX_ZOOM_LEVEL: f64 = 5.0; diff --git a/packages/tauri-app/src-tauri/src/local_windows.rs b/packages/tauri-app/src-tauri/src/local_windows.rs index c1d605c80..9f2f170bb 100644 --- a/packages/tauri-app/src-tauri/src/local_windows.rs +++ b/packages/tauri-app/src-tauri/src/local_windows.rs @@ -365,7 +365,7 @@ pub(crate) fn create_local_window( let result = builder .title("CodeNomad") .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) + .min_inner_size(client_state::MIN_WINDOW_WIDTH as f64, 600.0) .resizable(true) .fullscreen(false) .decorations(false) diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4e98295ee..0a9710b5c 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -495,16 +495,17 @@ fn browser_target_update( } #[tauri::command] -fn browser_target_action( +async fn browser_target_action( webview: tauri::Webview, app: AppHandle, state: tauri::State<'_, AppState>, payload: browser_controller::BrowserTargetAction, ) -> Result<(), String> { require_local_app_webview(&webview, &state)?; - state - .browser_controller - .action(&app, webview.label(), payload) + let controller = state.browser_controller.clone(); + let owner = webview.label().to_string(); + tauri::async_runtime::spawn_blocking(move || controller.action(&app, &owner, payload)) + .await.map_err(|error| error.to_string())? } #[tauri::command] @@ -990,7 +991,7 @@ fn open_remote_window_locked( .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) .title(title) .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0); + .min_inner_size(client_state::MIN_WINDOW_WIDTH as f64, 600.0); #[cfg(target_os = "macos")] let builder = builder.data_store_identifier(profile_identifier(&profile_key)); let window = match builder.build() { diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index 9695c2a67..86fab340d 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,8 +1,12 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, onCleanup, untrack, type Component } from "solid-js" import { runtimeEnv } from "../lib/runtime-env" +import { useI18n } from "../lib/i18n" +import emulationProfiles from "../lib/native/browser-emulation.json" +import type { BrowserEmulationPreset } from "../lib/native/browser-emulation" import { controlTauriBrowserTarget, + emulateBrowserTarget, nativeBrowserHost, onTauriBrowserNavigation, physicalBrowserBounds, @@ -46,8 +50,8 @@ const VIEWPORT_PRESETS: Record = (props) => { + const { t } = useI18n() + const [emulationBusy, setEmulationBusy] = createSignal(false) + const [emulationReady, setEmulationReady] = createSignal(false) const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) const [pathInput, setPathInput] = createSignal(props.initialAddress ?? "/") const [viewportPreset, setViewportPreset] = createSignal("responsive") @@ -133,6 +140,8 @@ export const BrowserFrame: Component = (props) => { // Iframe previews also run on HTTP LAN origins without crypto.randomUUID. let browserRegistrationId = nativeBrowserAvailable ? crypto.randomUUID() : "" let disposed = false + let appliedEmulation: { registrationId: string; preset: BrowserEmulationPreset } | undefined + let emulationGeneration = 0 const framePolicy = getBrowserFramePolicy(runtimeEnv) const canComment = createMemo(() => !nativeMode() && (framePolicy.canInspectDom || props.commentBridge) && Boolean(props.onToggleCommentMode && props.onCommentTarget)) @@ -140,6 +149,43 @@ export const BrowserFrame: Component = (props) => { const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) + const emulationProfile = (preset: BrowserViewportPreset): BrowserEmulationPreset => + preset === "mobile" || preset === "mobileLandscape" ? preset : "none" + const applyEmulation = async (registrationId: string, preset: BrowserEmulationPreset) => { + await emulateBrowserTarget(registrationId, preset) + if (!disposed && registrationId === browserRegistrationId) appliedEmulation = { registrationId, preset } + } + const restoreEmulation = async (registrationId: string) => { + // Readiness means we own a registered target, not that a profile succeeded. + // Keep failures retryable, including selecting the same retained template. + setEmulationReady(true) + const profile = untrack(() => emulationProfile(viewportPreset())) + if (profile === "none") return + const generation = ++emulationGeneration + setEmulationBusy(true) + try { await applyEmulation(registrationId, profile) } + catch (error) { + if (!disposed && registrationId === browserRegistrationId) reportNativeError(error) + } finally { + if (!disposed && generation === emulationGeneration) setEmulationBusy(false) + } + } + const selectViewport = async (preset: BrowserViewportPreset) => { + if (emulationBusy()) return + setViewportMenuOpen(false) + const previous = appliedEmulation?.registrationId === browserRegistrationId ? appliedEmulation.preset : undefined + const next = emulationProfile(preset) + if (previous === next || !nativeMode()) { setViewportPreset(preset); return } + setEmulationBusy(true) + const generation = ++emulationGeneration + const registrationId = browserRegistrationId + try { + await applyEmulation(registrationId, next) + if (!disposed && registrationId === browserRegistrationId) setViewportPreset(preset) + } catch (error) { if (registrationId === browserRegistrationId) reportNativeError(error) } + finally { if (!disposed && generation === emulationGeneration) setEmulationBusy(false) } + } + const getEditablePathFromUrl = (url: string): string => { try { const parsed = new URL(url, window.location.origin) @@ -351,6 +397,7 @@ export const BrowserFrame: Component = (props) => { const sessionId = props.sessionId if (!webviewReady || !sessionId || registering) return if (registered && registeredSessionId !== sessionId) { + setEmulationReady(false) const previousId = browserRegistrationId browserRegistrationId = crypto.randomUUID() registering = true @@ -364,6 +411,7 @@ export const BrowserFrame: Component = (props) => { } if (visible === registered) return if (!visible) { + setEmulationReady(false) if (retryTimer) clearTimeout(retryTimer) retryTimer = undefined if (registered) { @@ -386,7 +434,7 @@ export const BrowserFrame: Component = (props) => { sessionId, registrationId, guestWebContentsId: webview.getWebContentsId(), - }).then(() => { + }).then(async () => { registering = false if (!active) { void window.electronAPI?.unregisterBrowserTarget?.(registrationId).catch(reportNativeError) @@ -394,6 +442,7 @@ export const BrowserFrame: Component = (props) => { } registered = true registeredSessionId = sessionId + await restoreEmulation(registrationId) registrationFailures = 0 registrationErrorReported = false syncRegistration() @@ -416,6 +465,7 @@ export const BrowserFrame: Component = (props) => { webview.addEventListener("dom-ready", handleReady) syncRegistration() cleanupWebviewListeners = () => { + setEmulationReady(false) active = false if (retryTimer) clearTimeout(retryTimer) webview.removeEventListener("did-navigate", syncLocation) @@ -489,6 +539,7 @@ export const BrowserFrame: Component = (props) => { nativeVisible = false registeredSessionId = "" tauriRegistered = false + setEmulationReady(false) void unregisterTauriBrowserTarget(previousId).catch(reportNativeError).finally(() => { registering = false if (active) syncBounds() @@ -539,6 +590,7 @@ export const BrowserFrame: Component = (props) => { registered = true nativeVisible = true tauriRegistered = true + await restoreEmulation(registrationId) syncErrorReported = false if (nativeTarget() !== target) { await controlTauriBrowserTarget(registrationId, "navigate", nativeTarget()) @@ -681,7 +733,7 @@ export const BrowserFrame: Component = (props) => { return (
-
+
@@ -711,7 +763,7 @@ export const BrowserFrame: Component = (props) => { -
+
@@ -1201,7 +1218,6 @@ const InstanceShell2: Component = (props) => { instanceFolder={props.instance.folder} sessionId={NO_SESSION_DRAFT_SESSION_ID} isActive={props.isActiveInstance} - compactLayout={compactPromptLayout()} onSend={handleFirstPromptSend} onCommand={handleFirstPromptCommand} onRunShell={handleFirstPromptShell} @@ -1242,7 +1258,6 @@ const InstanceShell2: Component = (props) => { instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} isPhoneLayout={isPhoneLayout()} - compactPromptLayout={compactPromptLayout()} focusConversationOnActivate={focusConversationSessionId() === sessionId} onConversationFocusHandled={() => { if (focusConversationSessionId() === sessionId) setFocusConversationSessionId(null) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index adc6deb78..41707c99b 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,4 +1,4 @@ -import { Suspense, createEffect, createSignal, createUniqueId, lazy, on, onCleanup, onMount, Show } from "solid-js" +import { Suspense, createEffect, createSignal, createUniqueId, lazy, on, onCleanup, Show } from "solid-js" import { Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid" import { addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" @@ -22,8 +22,11 @@ import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" import { usePromptAside } from "./prompt-input/usePromptAside" +import { usePromptViewport } from "./prompt-input/usePromptViewport" import PromptAsideWindow from "./prompt-input/PromptAsideWindow" import { + MIN_PROMPT_FIELD_HEIGHT_RATIO, + MAX_PROMPT_FIELD_HEIGHT_RATIO, initializePromptInputHeight, persistPromptInputHeight, promptInputHeight, @@ -39,14 +42,7 @@ import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overfl const log = getLogger("actions") const LazyUnifiedPicker = lazy(() => import("./unified-picker")) const DEFAULT_PROMPT_FIELD_HEIGHT = 104 -const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6 -type SessionCenterWidthStep = "narrow" | "medium" | "wide" - -function getSessionCenterWidthStep(width: number): SessionCenterWidthStep { - if (width < 768) return "narrow" - if (width < 1280) return "medium" - return "wide" -} +const MIN_PROMPT_FIELD_HEIGHT = 44 type ResizeDragState = { pointerId: number @@ -101,13 +97,12 @@ export default function PromptInput(props: PromptInputProps) { const [mode, setMode] = createSignal("normal") const inputHeight = promptInputHeight const setInputHeight = setPromptInputHeight - const [autoInputHeight, setAutoInputHeight] = createSignal(null) const [isResizing, setIsResizing] = createSignal(false) - const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal(null) const SELECTION_INSERT_MAX_LENGTH = 2000 let textareaRef: HTMLTextAreaElement | undefined let wrapperRef: HTMLDivElement | undefined let fieldContainerRef: HTMLDivElement | undefined + const viewport = usePromptViewport(() => wrapperRef) let resizeDragState: ResizeDragState | undefined let submissionsInFlight = 0 let restoredQueuedPayload: Parameters[1] | undefined @@ -119,14 +114,31 @@ export default function PromptInput(props: PromptInputProps) { return t("promptInput.placeholder.default") } - const compactAutosizeEnabled = () => { - return compactLayoutEnabled() && inputHeight() === null + const defaultFieldHeight = () => viewport().height > 0 + ? Math.max(MIN_PROMPT_FIELD_HEIGHT, Math.floor(viewport().height * MIN_PROMPT_FIELD_HEIGHT_RATIO)) + : DEFAULT_PROMPT_FIELD_HEIGHT + const compactLayoutEnabled = () => defaultFieldHeight() < DEFAULT_PROMPT_FIELD_HEIGHT + const minimumFieldHeight = defaultFieldHeight + const heightPreference = (height: number) => ({ + ratio: height <= minimumFieldHeight() ? MIN_PROMPT_FIELD_HEIGHT_RATIO + : height >= computeMaxFieldHeight() ? MAX_PROMPT_FIELD_HEIGHT_RATIO + : height / (viewport().height || window.innerHeight), + }) + createEffect(() => { + const saved = inputHeight() + if (typeof saved === "number" && viewport().height > 0) { + persistPromptInputHeight(heightPreference(saved)) + } + }) + const effectiveInputHeight = () => { + const saved = inputHeight() + const desired = saved === null ? defaultFieldHeight() + : typeof saved === "number" ? saved + : saved.ratio === MIN_PROMPT_FIELD_HEIGHT_RATIO ? minimumFieldHeight() + : Math.round(viewport().height * saved.ratio) + return Math.min(computeMaxFieldHeight(), Math.max(minimumFieldHeight(), desired)) } - const compactLayoutEnabled = () => props.compactLayout && sessionCenterWidthStep() === "narrow" - - const effectiveInputHeight = () => inputHeight() ?? autoInputHeight() - const fieldHeightStyle = () => { const height = effectiveInputHeight() if (height === null) return undefined @@ -137,7 +149,7 @@ export default function PromptInput(props: PromptInputProps) { const textareaHeightStyle = () => { const height = effectiveInputHeight() if (height === null) return undefined - const overflowY: "auto" | "hidden" = inputHeight() !== null || height >= DEFAULT_PROMPT_FIELD_HEIGHT ? "auto" : "hidden" + const overflowY: "auto" | "hidden" = inputHeight() !== null || height >= defaultFieldHeight() ? "auto" : "hidden" if (inputHeight() !== null) { return { height: `${height}px`, @@ -148,31 +160,6 @@ export default function PromptInput(props: PromptInputProps) { return { height: `${height}px`, "overflow-y": overflowY } } - const measureCompactAutoHeight = () => { - const textarea = textareaRef - if (!textarea) return null - - const previousHeight = textarea.style.height - textarea.style.height = "auto" - const measuredHeight = textarea.scrollHeight - textarea.style.height = previousHeight - return Math.min(DEFAULT_PROMPT_FIELD_HEIGHT, measuredHeight) - } - - const syncCompactAutoHeight = () => { - if (!compactLayoutEnabled()) { - setAutoInputHeight(null) - return - } - const measuredHeight = measureCompactAutoHeight() - if (inputHeight() !== null) { - setAutoInputHeight(null) - if (measuredHeight !== null && inputHeight()! < measuredHeight) persistPromptInputHeight(measuredHeight) - return - } - setAutoInputHeight(measuredHeight) - } - const promptState = usePromptState({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, @@ -197,35 +184,6 @@ export default function PromptInput(props: PromptInputProps) { if (!prompt()) restoredQueuedPayload = undefined }) - onMount(() => { - const sessionCenter = wrapperRef?.closest("[data-session-center-width]") as HTMLElement | null - if (!sessionCenter) return - - const syncWidthStep = () => { - setSessionCenterWidthStep(getSessionCenterWidthStep(sessionCenter.getBoundingClientRect().width)) - } - - syncWidthStep() - const restoredHeight = inputHeight() - if (restoredHeight !== null) { - const clampedHeight = Math.min(restoredHeight, computeMaxFieldHeight()) - if (clampedHeight !== restoredHeight) persistPromptInputHeight(clampedHeight) - } - - if (typeof ResizeObserver === "undefined") return - const observer = new ResizeObserver(syncWidthStep) - observer.observe(sessionCenter) - onCleanup(() => observer.disconnect()) - }) - - createEffect(() => { - prompt() - inputHeight() - props.compactLayout - sessionCenterWidthStep() - queueMicrotask(syncCompactAutoHeight) - }) - const { attachments, isDragging, @@ -441,11 +399,9 @@ export default function PromptInput(props: PromptInputProps) { function computeMaxFieldHeight(): number { if (typeof window === "undefined") return DEFAULT_PROMPT_FIELD_HEIGHT - const sessionCenter = wrapperRef?.closest("[data-session-center-width]") - const measuredHeight = sessionCenter?.getBoundingClientRect().height ?? 0 - const availableHeight = measuredHeight > 0 ? measuredHeight : window.innerHeight + const availableHeight = viewport().height || window.innerHeight const maxHeight = Math.floor(availableHeight * MAX_PROMPT_FIELD_HEIGHT_RATIO) - return Math.max(DEFAULT_PROMPT_FIELD_HEIGHT, maxHeight) + return Math.max(defaultFieldHeight(), maxHeight) } function handleResizeStart(event: PointerEvent) { @@ -475,10 +431,10 @@ export default function PromptInput(props: PromptInputProps) { event.preventDefault() const deltaY = resizeDragState.startY - event.clientY const nextHeight = Math.max( - DEFAULT_PROMPT_FIELD_HEIGHT, + minimumFieldHeight(), Math.min(resizeDragState.maxHeight, resizeDragState.startHeight + deltaY), ) - setInputHeight(nextHeight) + setInputHeight(heightPreference(nextHeight)) } function handleResizeEnd(event: PointerEvent) { @@ -492,8 +448,8 @@ export default function PromptInput(props: PromptInputProps) { } function handleResizeKeyDown(event: KeyboardEvent) { - const currentHeight = inputHeight() ?? fieldContainerRef?.getBoundingClientRect().height ?? DEFAULT_PROMPT_FIELD_HEIGHT - const minimum = compactLayoutEnabled() ? measureCompactAutoHeight() ?? currentHeight : DEFAULT_PROMPT_FIELD_HEIGHT + const currentHeight = effectiveInputHeight() ?? fieldContainerRef?.getBoundingClientRect().height ?? defaultFieldHeight() + const minimum = minimumFieldHeight() const current = Math.max(minimum, currentHeight) const max = computeMaxFieldHeight() const next = event.key === "ArrowUp" @@ -507,7 +463,7 @@ export default function PromptInput(props: PromptInputProps) { : null if (next === null) return event.preventDefault() - persistPromptInputHeight(next) + persistPromptInputHeight(heightPreference(next)) } onCleanup(() => { @@ -667,7 +623,7 @@ export default function PromptInput(props: PromptInputProps) { function handleResizeMaximize(event: MouseEvent) { event.preventDefault() - persistPromptInputHeight(computeMaxFieldHeight()) + persistPromptInputHeight(heightPreference(computeMaxFieldHeight())) textareaRef?.focus() } @@ -943,12 +899,9 @@ export default function PromptInput(props: PromptInputProps) { tabIndex={0} role="separator" aria-orientation="horizontal" - aria-valuemin={Math.round(compactLayoutEnabled() ? measureCompactAutoHeight() ?? 0 : DEFAULT_PROMPT_FIELD_HEIGHT)} + aria-valuemin={Math.round(minimumFieldHeight())} aria-valuemax={computeMaxFieldHeight()} - aria-valuenow={Math.round(Math.max( - compactLayoutEnabled() ? measureCompactAutoHeight() ?? 0 : DEFAULT_PROMPT_FIELD_HEIGHT, - inputHeight() ?? autoInputHeight() ?? DEFAULT_PROMPT_FIELD_HEIGHT, - ))} + aria-valuenow={Math.round(effectiveInputHeight() ?? defaultFieldHeight())} aria-label={t("promptInput.resizeHandle.title")} title={t("promptInput.resizeHandle.title")} /> @@ -995,7 +948,7 @@ export default function PromptInput(props: PromptInputProps) { onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} disabled={props.disabled} - rows={compactAutosizeEnabled() ? 1 : 5} + rows={compactLayoutEnabled() ? 1 : 5} spellcheck={false} autocorrect="off" autoCapitalize="off" diff --git a/packages/ui/src/components/prompt-input/height-state.test.ts b/packages/ui/src/components/prompt-input/height-state.test.ts index 34d2c9e4e..784b2a4ae 100644 --- a/packages/ui/src/components/prompt-input/height-state.test.ts +++ b/packages/ui/src/components/prompt-input/height-state.test.ts @@ -20,12 +20,21 @@ describe("prompt input height state", () => { assert.equal(promptInputHeight(), 320) persistPromptInputHeight(320, (_key, value) => writes.push(value)) + persistPromptInputHeight({ ratio: 0.3 }, (_key, value) => writes.push(value)) + initializePromptInputHeight(() => "999") + assert.deepEqual(promptInputHeight(), { ratio: 0.3 }) + assert.deepEqual(parsePromptInputHeight(writes[1]), { ratio: 0.3 }) persistPromptInputHeight(null, (_key, value) => writes.push(value)) - assert.deepEqual(writes, ["320", "auto"]) + assert.deepEqual(writes, ["320", "ratio:0.3", "auto"]) assert.equal(promptInputHeight(), null) }) it("rejects malformed stored heights", () => { + for (const value of ["ratio:", "ratio:NaN", "ratio:Infinity", "ratio:-1", "ratio:0.07", "ratio:0.7"]) { + assert.equal(parsePromptInputHeight(value), null) + } + assert.deepEqual(parsePromptInputHeight("ratio:0.08"), { ratio: 0.08 }) + assert.deepEqual(parsePromptInputHeight("ratio:0.6"), { ratio: 0.6 }) assert.deepEqual([null, "auto", "", "-1", "NaN", "10001"].map(parsePromptInputHeight), [ null, null, diff --git a/packages/ui/src/components/prompt-input/height-state.ts b/packages/ui/src/components/prompt-input/height-state.ts index 4838cbfd3..a2e7067a4 100644 --- a/packages/ui/src/components/prompt-input/height-state.ts +++ b/packages/ui/src/components/prompt-input/height-state.ts @@ -4,11 +4,19 @@ import { readClientLayoutValue, writeClientLayoutValue } from "../../stores/clie const STORAGE_KEY = "opencode-session-prompt-input-height-v1" const AUTO_HEIGHT = "auto" const MAX_STORED_HEIGHT = 10_000 +export const MIN_PROMPT_FIELD_HEIGHT_RATIO = 0.08 +export const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6 -const [promptInputHeight, setPromptInputHeightValue] = createSignal(null) +// Numbers are legacy pixel heights, converted once the composer is measured. +type PromptInputHeight = number | { ratio: number } | null +const [promptInputHeight, setPromptInputHeightValue] = createSignal(null) let initialized = false -export function parsePromptInputHeight(value: string | null): number | null { +export function parsePromptInputHeight(value: string | null): PromptInputHeight { + if (value?.startsWith("ratio:")) { + const ratio = Number(value.slice(6)) + return Number.isFinite(ratio) && ratio >= MIN_PROMPT_FIELD_HEIGHT_RATIO && ratio <= MAX_PROMPT_FIELD_HEIGHT_RATIO ? { ratio } : null + } if (value === null || value === AUTO_HEIGHT || !/^\d+$/.test(value)) return null const height = Number(value) return height > 0 && height <= MAX_STORED_HEIGHT ? height : null @@ -22,17 +30,18 @@ export function initializePromptInputHeight( setPromptInputHeightValue(parsePromptInputHeight(read(STORAGE_KEY))) } -export function setPromptInputHeight(value: number | null): void { +export function setPromptInputHeight(value: PromptInputHeight): void { initialized = true setPromptInputHeightValue(value) } export function persistPromptInputHeight( - value: number | null = promptInputHeight(), + value: PromptInputHeight = promptInputHeight(), write: (key: string, value: string) => void = writeClientLayoutValue, ): void { setPromptInputHeight(value) - write(STORAGE_KEY, value === null ? AUTO_HEIGHT : String(Math.round(value))) + write(STORAGE_KEY, value === null ? AUTO_HEIGHT + : typeof value === "number" ? String(Math.round(value)) : `ratio:${value.ratio}`) } export { promptInputHeight } diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index 2006263a6..d0ad1a11d 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -27,7 +27,6 @@ export interface PromptInputProps { isActive?: boolean // Phone/tablet layouts should keep the expanded prompt more compact. - compactLayout?: boolean onSend: (prompt: string, attachments: Attachment[], delivery: PromptDelivery, restoredPayload?: SessionInboxUserPayload) => Promise onCommand?: (commandName: string, args: string) => Promise onRunShell?: (command: string) => Promise diff --git a/packages/ui/src/components/prompt-input/usePromptViewport.ts b/packages/ui/src/components/prompt-input/usePromptViewport.ts new file mode 100644 index 000000000..988278d3e --- /dev/null +++ b/packages/ui/src/components/prompt-input/usePromptViewport.ts @@ -0,0 +1,41 @@ +import { createSignal, onCleanup, onMount } from "solid-js" + +// The keyboard can shrink only visualViewport, leaving the layout viewport +// and conversation container unchanged. Keep their visible intersection. +export function usePromptViewport(wrapper: () => HTMLElement | undefined) { + const [size, setSize] = createSignal({ width: 0, height: 0 }, { + equals: (a, b) => a.width === b.width && a.height === b.height, + }) + onMount(() => { + const container = wrapper()?.closest("[data-session-center-width]") ?? wrapper()?.closest(".session-view") + if (!container) return + const viewport = window.visualViewport + let frame = 0 + const update = () => { + const rect = container.getBoundingClientRect() + if (!rect.width || !rect.height) return + const top = viewport?.offsetTop ?? 0 + const bottom = top + (viewport?.height ?? window.innerHeight) + const height = Math.max(0, Math.min(rect.bottom, bottom) - Math.max(rect.top, top)) + if (height > 0) setSize({ width: rect.width, height }) + } + const schedule = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(update) + } + const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(schedule) + observer?.observe(container) + window.addEventListener("resize", schedule) + viewport?.addEventListener("resize", schedule) + viewport?.addEventListener("scroll", schedule) + update() + onCleanup(() => { + cancelAnimationFrame(frame) + observer?.disconnect() + window.removeEventListener("resize", schedule) + viewport?.removeEventListener("resize", schedule) + viewport?.removeEventListener("scroll", schedule) + }) + }) + return size +} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index cb17b5ef6..5af58731a 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -43,7 +43,6 @@ interface SessionViewProps { instanceFolder: string escapeInDebounce: boolean isPhoneLayout?: boolean - compactPromptLayout?: boolean focusConversationOnActivate?: boolean onConversationFocusHandled?: () => void showSidebarToggle?: boolean @@ -669,7 +668,6 @@ export const SessionView: Component = (props) => { instanceFolder={session()?.location.directory ?? props.instanceFolder} sessionId={props.sessionId} isActive={props.isActive} - compactLayout={props.compactPromptLayout} onSend={handleSendMessage} onRunShell={handleRunShell} escapeInDebounce={props.escapeInDebounce} diff --git a/packages/ui/src/components/transcript-filters.tsx b/packages/ui/src/components/transcript-filters.tsx index e383244fe..821dad311 100644 --- a/packages/ui/src/components/transcript-filters.tsx +++ b/packages/ui/src/components/transcript-filters.tsx @@ -9,18 +9,28 @@ import { useConfig, type VisibilityPreference } from "../stores/preferences" import { getMessageContentIcon } from "./message-content-icons" import { transcriptVisibility, transcriptVisibilityPatch, transcriptVisibilityRows } from "./transcript-visibility" -export default function TranscriptFilters() { +interface TranscriptFiltersProps { + open?: boolean + onOpenChange?: (open: boolean) => void + overflowAnchor?: () => HTMLElement | undefined +} + +export default function TranscriptFilters(props: TranscriptFiltersProps = {}) { const { t } = useI18n() const { preferences, updatePreferences } = useConfig() const rows = createMemo(() => transcriptVisibilityRows(t)) const [saving, setSaving] = createSignal(false) return ( - + (props.overflowAnchor?.() ?? anchor)?.getBoundingClientRect()}> - + { + const anchor = props.overflowAnchor?.() + if (anchor) { event.preventDefault(); anchor.focus() } + }}>
{t("transcriptFilters.title")}
diff --git a/packages/ui/src/lib/i18n/messages/de/session.ts b/packages/ui/src/lib/i18n/messages/de/session.ts index 681824860..847bf388c 100644 --- a/packages/ui/src/lib/i18n/messages/de/session.ts +++ b/packages/ui/src/lib/i18n/messages/de/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "Lädt mit mobiler Kennung, Touch und Pixeldichte neu. Browser-Engine, Tastatur und Systemleisten werden nicht simuliert.", + "browser.viewport.emulationUnavailable": "Die Geräteemulation erfordert eine native Webvorschau in der Desktop-App.", "sessionList.filter.includeMainSessions": "Hauptsitzungen anzeigen", "sessionList.filter.includeSubsessions": "Untersitzungen anzeigen", "session.pruning.maintenance_required": "Die Sitzung oder ihr Speicher ist beschäftigt. Warte, bis der aktuelle Vorgang abgeschlossen ist, und versuche die Bereinigung erneut.", diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index 8e4677818..189d62bbb 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "Reloads with mobile identity, touch and pixel density. Browser engine, keyboard and system bars are not simulated.", + "browser.viewport.emulationUnavailable": "Device emulation requires a native desktop Web preview.", "sessionList.filter.includeMainSessions": "Show main sessions", "sessionList.filter.includeSubsessions": "Show subsessions", "session.pruning.maintenance_required": "The session or its storage is busy. Wait for the current operation to finish, then retry cleanup.", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index d816e428b..d00ae86d3 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "Recarga con identidad móvil, entrada táctil y densidad de píxeles. No simula el motor del navegador, el teclado ni las barras del sistema.", + "browser.viewport.emulationUnavailable": "La emulación requiere una vista previa web nativa en la aplicación de escritorio.", "sessionList.filter.includeMainSessions": "Mostrar sesiones principales", "sessionList.filter.includeSubsessions": "Mostrar subsesiones", "session.pruning.maintenance_required": "La sesión o su almacenamiento está ocupado. Espera a que termine la operación actual y vuelve a intentar la limpieza.", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 231d7b7f1..0e487c5de 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "Recharge avec identité mobile, tactile et densité de pixels. Le moteur du navigateur, le clavier et les barres système ne sont pas simulés.", + "browser.viewport.emulationUnavailable": "L’émulation nécessite un aperçu Web natif dans l’application de bureau.", "sessionList.filter.includeMainSessions": "Afficher les sessions principales", "sessionList.filter.includeSubsessions": "Afficher les sous-sessions", "session.pruning.maintenance_required": "La session ou son stockage est occupé. Attendez la fin de l’opération en cours, puis réessayez le nettoyage.", diff --git a/packages/ui/src/lib/i18n/messages/he/session.ts b/packages/ui/src/lib/i18n/messages/he/session.ts index 0d97ef06e..c51961996 100644 --- a/packages/ui/src/lib/i18n/messages/he/session.ts +++ b/packages/ui/src/lib/i18n/messages/he/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "טוען מחדש עם זיהוי נייד, מגע וצפיפות פיקסלים. מנוע הדפדפן, המקלדת וסרגלי המערכת אינם מדומים.", + "browser.viewport.emulationUnavailable": "הדמיית מכשיר דורשת תצוגה מקדימה מקורית של האינטרנט ביישום למחשב.", "sessionList.filter.includeMainSessions": "הצגת סשנים ראשיים", "sessionList.filter.includeSubsessions": "הצגת תת־סשנים", "session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index af55688ba..71cdfb616 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "モバイル識別情報、タッチ、ピクセル密度を設定して再読み込みします。ブラウザーエンジン、キーボード、システムバーは再現しません。", + "browser.viewport.emulationUnavailable": "デバイスのエミュレーションにはデスクトップアプリのネイティブ Web プレビューが必要です。", "sessionList.filter.includeMainSessions": "メインセッションを表示", "sessionList.filter.includeSubsessions": "サブセッションを表示", "session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。", diff --git a/packages/ui/src/lib/i18n/messages/ne/session.ts b/packages/ui/src/lib/i18n/messages/ne/session.ts index 30eff8949..b9ef4673b 100644 --- a/packages/ui/src/lib/i18n/messages/ne/session.ts +++ b/packages/ui/src/lib/i18n/messages/ne/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "मोबाइल पहिचान, टच र पिक्सेल घनत्वसहित पुनः लोड गर्छ। ब्राउजर इन्जिन, किबोर्ड र प्रणाली बारहरू अनुकरण गरिँदैनन्।", + "browser.viewport.emulationUnavailable": "उपकरण अनुकरणका लागि डेस्कटप एपको नेटिभ वेब पूर्वावलोकन चाहिन्छ।", "sessionList.filter.includeMainSessions": "मुख्य सत्रहरू देखाउनुहोस्", "sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्", "session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index d847cdedd..93f4395de 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "Перезагрузка с мобильной идентификацией, сенсорным вводом и плотностью пикселей. Движок браузера, клавиатура и системные панели не эмулируются.", + "browser.viewport.emulationUnavailable": "Для эмуляции устройства нужен нативный веб-предпросмотр в настольном приложении.", "sessionList.filter.includeMainSessions": "Показывать основные сессии", "sessionList.filter.includeSubsessions": "Показывать подсессии", "session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index 295abcdf4..572eb4416 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -1,4 +1,6 @@ export const sessionMessages = { + "browser.viewport.emulationHint": "使用移动设备标识、触控和像素密度重新加载。不模拟浏览器引擎、键盘或系统栏。", + "browser.viewport.emulationUnavailable": "设备模拟需要桌面应用中的原生网页预览。", "sessionList.filter.includeMainSessions": "显示主会话", "sessionList.filter.includeSubsessions": "显示子会话", "session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。", diff --git a/packages/ui/src/lib/native/browser-emulation.json b/packages/ui/src/lib/native/browser-emulation.json new file mode 100644 index 000000000..8024c63af --- /dev/null +++ b/packages/ui/src/lib/native/browser-emulation.json @@ -0,0 +1,5 @@ +{ + "mobile": { "width": 390, "height": 844 }, + "deviceScaleFactor": 2.75, + "userAgent": "Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{version} Mobile Safari/537.36" +} diff --git a/packages/ui/src/lib/native/browser-emulation.ts b/packages/ui/src/lib/native/browser-emulation.ts new file mode 100644 index 000000000..e7e038675 --- /dev/null +++ b/packages/ui/src/lib/native/browser-emulation.ts @@ -0,0 +1,19 @@ +import profiles from "./browser-emulation.json" + +export type BrowserEmulationPreset = "none" | "mobile" | "mobileLandscape" + +export function browserEmulationProfile(preset: unknown) { + if (preset === "none") return null + if (typeof preset !== "string") throw new Error("Invalid browser emulation profile") + const landscape = preset.endsWith("Landscape") + const size = landscape ? preset.slice(0, -"Landscape".length) : preset + if (size !== "mobile") throw new Error("Invalid browser emulation profile") + const { width, height } = profiles[size] + return { + width: landscape ? height : width, + height: landscape ? width : height, + deviceScaleFactor: profiles.deviceScaleFactor, + orientation: landscape ? "landscapePrimary" : "portraitPrimary", + angle: landscape ? 90 : 0, + } +} diff --git a/packages/ui/src/lib/native/browser.ts b/packages/ui/src/lib/native/browser.ts index 8df7f15fb..738f9f061 100644 --- a/packages/ui/src/lib/native/browser.ts +++ b/packages/ui/src/lib/native/browser.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import type { RuntimeEnvironment } from "../runtime-env" +import type { BrowserEmulationPreset } from "./browser-emulation" export interface BrowserTargetBounds { x: number @@ -62,6 +63,11 @@ export async function unregisterTauriBrowserTarget(registrationId: string): Prom await invoke("browser_target_unregister", { registrationId }) } +export async function emulateBrowserTarget(registrationId: string, preset: BrowserEmulationPreset): Promise { + if (window.electronAPI?.emulateBrowserTarget) return window.electronAPI.emulateBrowserTarget(registrationId, preset) + await invoke("browser_target_action", { payload: { registrationId, action: "emulate", preset } }) +} + export async function onNativeBrowserOpen( callback: (payload: { sessionID: string; url: string; requestID: string }) => void, ): Promise<() => void> { diff --git a/packages/ui/src/styles/components/prompt-context-controls.css b/packages/ui/src/styles/components/prompt-context-controls.css index cb3e7d995..14faadf3a 100644 --- a/packages/ui/src/styles/components/prompt-context-controls.css +++ b/packages/ui/src/styles/components/prompt-context-controls.css @@ -65,6 +65,33 @@ } } +/* Use the full conversation width, before the visible timeline takes space + from the composer. Worktree is the first cell. */ +@container session-center (width < 460px) { + .prompt-context-controls[data-has-worktree="true"] { + grid-template-columns: var(--touch-target-size-compact) minmax(0, 0.85fr) minmax(0, 1.35fr) minmax(0, 1fr); + } + + .prompt-context-controls[data-has-worktree="true"] > .sidebar-selector:first-child .selector-trigger > div { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .prompt-context-controls[data-has-worktree="true"] > .sidebar-selector:first-child .selector-trigger { + justify-content: center; + padding-inline: 0; + gap: 0; + } + + .prompt-context-controls[data-has-worktree="true"] > .sidebar-selector:first-child .selector-trigger-icon { + margin-inline-start: 0; + } +} + @media (pointer: coarse) { .prompt-context-controls .selector-trigger { min-height: var(--touch-target-size); diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index a163ce5e5..fd6d2ded0 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -15,14 +15,6 @@ grid-template-columns: minmax(0, 1fr) 40px; } -.session-center-column[data-session-header-density="4"] .message-layout--with-timeline { - grid-template-columns: minmax(0, 1fr); -} - -.session-center-column[data-session-header-density="4"] .message-timeline-sidebar { - display: none; -} - .message-stream-shell { min-height: 0; display: flex; @@ -238,6 +230,6 @@ } .message-timeline-segment { - min-height: var(--touch-target-size); + min-height: var(--control-height-sm); } } diff --git a/packages/ui/src/styles/messaging/prompt-input-footer.css b/packages/ui/src/styles/messaging/prompt-input-footer.css index 8864f124f..c774f0672 100644 --- a/packages/ui/src/styles/messaging/prompt-input-footer.css +++ b/packages/ui/src/styles/messaging/prompt-input-footer.css @@ -88,24 +88,10 @@ border-radius: 0; } -@container prompt-composer (max-width: 360px) { - .prompt-input-footer { - flex-wrap: wrap; - } - - .prompt-input-footer-context { - flex-basis: 100%; - border-bottom: 1px solid var(--border-divider); - } - - .prompt-input-footer-actions { - width: 100%; - justify-content: flex-end; - border-inline-start: 0; - } -} - @media (pointer: coarse) { + .prompt-input-footer { + --touch-target-size: var(--touch-target-size-compact); + } .prompt-input-footer .prompt-actions-menu-trigger.action-overflow-trigger { flex-basis: var(--touch-target-size); } diff --git a/packages/ui/src/styles/messaging/session-timeline-rail.css b/packages/ui/src/styles/messaging/session-timeline-rail.css index 0298f87f9..2d7b71c95 100644 --- a/packages/ui/src/styles/messaging/session-timeline-rail.css +++ b/packages/ui/src/styles/messaging/session-timeline-rail.css @@ -49,14 +49,19 @@ } } -.session-center-column[data-session-header-density="4"] .session-view { - padding-inline-end: 0; -} +/* Use conversation width, never the changing header controls or viewport height. */ +@container session-center (width < 420px) { + .session-view:has(> .session-timeline-slot .message-timeline-sidebar) { + padding-inline-end: 0; + } -.session-center-column[data-session-header-density="4"] .session-timeline-slot { - display: none; -} + .session-timeline-slot, + .message-timeline-sidebar, + .session-view:has(> .session-timeline-slot .message-timeline-sidebar) .prompt-input-footer::after { + display: none; + } -.session-center-column[data-session-header-density="4"] .session-view .prompt-input-footer::after { - display: none; + .message-layout--with-timeline { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/packages/ui/src/styles/panels.css b/packages/ui/src/styles/panels.css index 4521493d7..e2e230cdd 100644 --- a/packages/ui/src/styles/panels.css +++ b/packages/ui/src/styles/panels.css @@ -4,6 +4,7 @@ @import "./panels/modal.css"; @import "./panels/panel-shell.css"; @import "./panels/session-layout.css"; +@import "./panels/session-toolbar-touch.css"; @import "./components/session-row-actions.css"; @import "./components/session-tree.css"; @import "./components/transcript-filters.css"; diff --git a/packages/ui/src/styles/panels/session-toolbar-touch.css b/packages/ui/src/styles/panels/session-toolbar-touch.css new file mode 100644 index 000000000..fa285897a --- /dev/null +++ b/packages/ui/src/styles/panels/session-toolbar-touch.css @@ -0,0 +1,14 @@ +/* Dense conversation chrome uses compact targets without widening icon gaps. */ +@media (pointer: coarse) { + .session-toolbar { + --touch-target-size: var(--touch-target-size-compact); + } + + .session-toolbar .panel-header-actions { + gap: 0; + } + + .session-toolbar .connection-status-meta { + gap: var(--space-xs); + } +} diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index b2680512e..9b6094fb2 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -165,6 +165,7 @@ --control-height: 28px; --selector-height: 36px; --touch-target-size: 40px; + --touch-target-size-compact: 32px; --scrollbar-size: 8px; --icon-button-size: 26px; --icon-size: 14px; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 9a192ffc5..5dcd9f7fe 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -71,6 +71,7 @@ declare global { clearClientState?: (accessToken: string) => Promise registerBrowserTarget?: (payload: { sessionId: string; registrationId: string; guestWebContentsId: number }) => Promise<{ ok: true }> unregisterBrowserTarget?: (registrationId: string) => Promise<{ ok: true }> + emulateBrowserTarget?: (registrationId: string, preset: import("../lib/native/browser-emulation").BrowserEmulationPreset) => Promise claimBrowserOpen?: (requestID: string) => Promise releaseBrowserOpen?: (requestID: string) => Promise onBrowserOpenRequest?: (callback: (payload: { sessionID: string; url: string; requestID: string }) => void) => () => void diff --git a/packages/ui/tests/browser/browser-emulation-native.test.ts b/packages/ui/tests/browser/browser-emulation-native.test.ts new file mode 100644 index 000000000..1db969a24 --- /dev/null +++ b/packages/ui/tests/browser/browser-emulation-native.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createServer } from "node:http" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" +import { build } from "esbuild" +import electronPath from "electron" +import { _electron } from "playwright" + +test("native Chromium emulation applies mobile media, UA, density and orientation and fully resets", async () => { + const temp = await mkdtemp(join(process.env.CODENOMAD_TEST_TEMP || (process.platform === "win32" ? join(process.env.LOCALAPPDATA!, "Temp", "opencode") : tmpdir()), "emulation-")) + const server = createServer((_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end('Mobile fixture') + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + let app: Awaited> | undefined + try { + const modulePath = join(temp, "emulation.cjs") + await build({ entryPoints: [fileURLToPath(new URL("../../../electron-app/electron/main/browser-emulation.ts", import.meta.url))], outfile: modulePath, bundle: true, platform: "node", format: "cjs", external: ["electron"] }) + const env = { ...process.env, CODENOMAD_TEST_PROFILE: join(temp, "profile"), CODENOMAD_EMULATION_MODULE: modulePath, + CODENOMAD_EMULATION_URL: `http://127.0.0.1:${(server.address() as any).port}/` } + delete env.ELECTRON_RUN_AS_NODE + app = await _electron.launch({ executablePath: process.env.CODENOMAD_TEST_ELECTRON || electronPath, + args: ["--no-sandbox", fileURLToPath(new URL("fixtures/browser-emulation-electron.cjs", import.meta.url))], env }) + const page = await app.firstWindow() + await page.waitForLoadState() + const snapshot = () => app!.evaluate(() => (globalThis as any).emulationFixture.snapshot()) + const baseline = await snapshot() + const dimensions = { mobile: [390, 844], mobileLandscape: [844, 390] } + for (const preset of ["mobile", "mobileLandscape", "none"] as const) { + const loaded = page.waitForEvent("load") + await app.evaluate((_electron, preset) => (globalThis as any).emulationFixture.apply(preset), preset) + await loaded + const result = await snapshot() + if (preset === "none") assert.deepEqual(result, baseline) + else { + assert.equal(result.width, dimensions[preset][0]) + assert.equal(result.height, dimensions[preset][1]) + assert.equal(result.dpr, 2.75) + assert.equal(result.coarse, true) + assert.equal(result.hover, false) + assert.equal(result.mobile, true) + assert.equal(result.hintPlatform, "Android") + assert.equal(result.platform, "Linux armv8l") + assert.equal(result.screenWidth, result.width) + assert.equal(result.screenHeight, result.height) + assert.equal(result.touch, 5) + assert.match(result.ua, /Android 11/) + assert.doesNotMatch(result.ua, /Pixel/) + assert.equal(result.orientation, preset.endsWith("Landscape") ? "landscape-primary" : "portrait-primary") + await app.evaluate(() => (globalThis as any).emulationFixture.accessibility()) + assert.deepEqual(await snapshot(), result, "accessibility inspection keeps emulation active") + await page.reload() + assert.deepEqual(await snapshot(), result, "overrides survive document navigation") + } + } + await assert.rejects(app.evaluate(() => (globalThis as any).emulationFixture.apply("untrusted-profile")), /Invalid browser emulation profile/) + } finally { + await app?.close() + await new Promise(resolve => server.close(() => resolve())) + await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) diff --git a/packages/ui/tests/browser/browser-frame-native.test.ts b/packages/ui/tests/browser/browser-frame-native.test.ts index d761f4692..317bcc3b2 100644 --- a/packages/ui/tests/browser/browser-frame-native.test.ts +++ b/packages/ui/tests/browser/browser-frame-native.test.ts @@ -58,6 +58,100 @@ const snapshot = (page: Page) => page.evaluate(() => { return { calls: fixture.calls, errors: fixture.errors, locations: fixture.locations, listeners: fixture.listenerCount() } }) +test("viewport menu distinguishes size from device emulation and restores desktop overrides", async () => { + const page = await browser.newPage({ userAgent: "Windows fixture" }) + try { + await page.goto(url) + await page.waitForFunction(() => (window as any).nativeFixture?.calls.some((c: any) => c.command === "browser_target_register")) + const menu = page.getByRole("button", { name: "Viewport", exact: true }) + assert.equal(await page.getByRole("button", { name: /Landscape orientation|Orientation paysage/ }).count(), 0) + await menu.click() + assert.deepEqual(await page.getByRole("menuitemradio").allTextContents(), ["Responsive", "Desktop (1440 × 900)", "Tablet (768 × 1024)", "Tablet landscape (1024 × 768)", "Mobile (390 × 844)", "Mobile landscape (844 × 390)"]) + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + await page.waitForFunction(() => (window as any).nativeFixture.calls.some((c: any) => c.payload?.preset === "mobile")) + await menu.click() + assert.equal(await page.getByRole("menuitemradio", { name: /390 × 844/ }).getAttribute("aria-checked"), "true") + await page.getByRole("menuitemradio", { name: /844 × 390/ }).click() + await menu.click() + if (process.env.CODENOMAD_MOBILE_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_MOBILE_CAPTURE}/phone-templates-menu.png` }) + await page.evaluate(() => { (window as any).nativeFixture.failEmulation = true }) + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + await page.getByRole("alertdialog").waitFor() + await page.evaluate(() => { (window as any).nativeFixture.overlay(false); (window as any).nativeFixture.failEmulation = false }) + await menu.click() + assert.equal(await page.getByRole("menuitemradio", { name: /844 × 390/ }).getAttribute("aria-checked"), "true", "failed emulation preserves the applied selection") + await page.getByRole("menuitemradio").first().click() + await page.waitForFunction(() => (window as any).nativeFixture.calls.some((c: any) => c.payload?.preset === "none")) + assert.deepEqual((await snapshot(page)).calls.filter((c: any) => c.payload?.action === "emulate").map((c: any) => c.payload.preset), ["mobile", "mobileLandscape", "mobile", "none"]) + } finally { await page.close() } +}) + +for (const host of ["electron", "tauri"]) { + test(`${host} mobile emulation remains retryable after retained-profile application fails`, async () => { + const fixture = host === "electron" ? await openElectronFixture() : undefined + const page = fixture?.page ?? await browser.newPage({ userAgent: "Windows fixture" }) + try { + await page.goto(`${url}?host=${host}`) + await page.waitForFunction(() => (window as any).nativeFixture?.calls.some((c: any) => c.command === "browser_target_register")) + const menu = page.getByRole("button", { name: "Viewport", exact: true }) + await menu.click() + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + await page.evaluate(() => { + ;(window as any).nativeFixture.failEmulation = true + ;(window as any).nativeFixture.session("replacement-session") + }) + await page.getByRole("alertdialog").waitFor() + await page.evaluate(() => { + ;(window as any).nativeFixture.failEmulation = false + ;(window as any).nativeFixture.overlay(false) + }) + await menu.click() + assert.equal(await page.getByRole("menuitemradio", { name: /390 × 844/ }).isEnabled(), true) + assert.equal(await page.getByRole("menuitemradio", { name: /844 × 390/ }).isEnabled(), true) + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + await page.waitForFunction(() => (window as any).nativeFixture.calls.filter((c: any) => c.payload?.preset === "mobile").length === 3) + await menu.click() + await page.getByRole("menuitemradio").first().click() + await menu.click() + assert.equal(await page.getByRole("menuitemradio", { name: /390 × 844/ }).isEnabled(), true) + assert.deepEqual((await snapshot(page)).calls.filter((c: any) => c.payload?.action === "emulate").map((c: any) => c.payload.preset), ["mobile", "mobile", "mobile", "none"]) + } finally { if (fixture) await fixture.close(); else await page.close() } + }) +} + +test("fixed viewport templates remain keyboard accessible in a narrow preview", async () => { + const page = await browser.newPage({ userAgent: "Windows fixture", viewport: { width: 320, height: 740 } }) + try { + await page.goto(url) + await page.evaluate(() => { document.getElementById("preview")!.style.width = "100%" }) + const menu = page.getByRole("button", { name: "Viewport", exact: true }) + await menu.click() + const bounds = await page.getByRole("menu").boundingBox() + assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 320) + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + await menu.click() + await page.getByRole("menuitemradio", { name: /844 × 390/ }).focus() + await page.keyboard.press("Space") + await page.waitForFunction(() => (window as any).nativeFixture.calls.some((c: any) => c.payload?.preset === "mobileLandscape")) + const close = page.locator(".window-close-button") + const closeBounds = await close.boundingBox() + assert.ok(closeBounds && closeBounds.x + closeBounds.width <= 320) + await close.click() + assert.equal(await menu.count(), 0) + } finally { await page.close() } +}) + +test("iframe preview keeps the original mobile templates as dimensions only", async () => { + const page = await browser.newPage() + try { + await page.goto(`${url}?host=web`) + await page.getByRole("button", { name: "Viewport", exact: true }).click() + await page.getByRole("menuitemradio", { name: /390 × 844/ }).click() + assert.equal(await page.locator("iframe").evaluate(el => Math.round(el.getBoundingClientRect().width)), 390) + assert.equal((await snapshot(page)).calls.some((c: any) => c.payload?.action === "emulate"), false) + } finally { await page.close() } +}) + async function openElectronFixture() { const temp = process.env.CODENOMAD_TEST_TEMP || (process.platform === "win32" ? join(process.env.LOCALAPPDATA!, "Temp", "opencode") : tmpdir()) diff --git a/packages/ui/tests/browser/fixtures/browser-emulation-electron.cjs b/packages/ui/tests/browser/fixtures/browser-emulation-electron.cjs new file mode 100644 index 000000000..059ebab16 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/browser-emulation-electron.cjs @@ -0,0 +1,23 @@ +const { app, BrowserWindow } = require("electron") +const { setBrowserEmulation } = require(process.env.CODENOMAD_EMULATION_MODULE) +app.setPath("userData", process.env.CODENOMAD_TEST_PROFILE) +app.whenReady().then(() => { + const window = new BrowserWindow({ show: false, width: 1100, height: 950, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }) + globalThis.emulationFixture = { + apply: preset => setBrowserEmulation(window.webContents, preset), + snapshot: () => window.webContents.executeJavaScript(`({ width: innerWidth, height: innerHeight, + dpr: devicePixelRatio, coarse: matchMedia('(pointer: coarse)').matches, + hover: matchMedia('(hover: hover)').matches, mobile: navigator.userAgentData.mobile, + ua: navigator.userAgent, platform: navigator.platform, hintPlatform: navigator.userAgentData.platform, + screenWidth: screen.width, screenHeight: screen.height, + touch: navigator.maxTouchPoints, orientation: screen.orientation.type })`), + accessibility: async () => { + const debuggerSession = window.webContents.debugger + await debuggerSession.sendCommand("Accessibility.enable") + await debuggerSession.sendCommand("Accessibility.getFullAXTree") + }, + } + window.loadURL(process.env.CODENOMAD_EMULATION_URL) +}) +app.on("window-all-closed", () => app.quit()) diff --git a/packages/ui/tests/browser/fixtures/browser-frame-native-bridge.ts b/packages/ui/tests/browser/fixtures/browser-frame-native-bridge.ts index f4c1e843d..774496f9c 100644 --- a/packages/ui/tests/browser/fixtures/browser-frame-native-bridge.ts +++ b/packages/ui/tests/browser/fixtures/browser-frame-native-bridge.ts @@ -13,6 +13,7 @@ export const nativeFixture = { errors: [] as string[], locations: [] as string[], failUpdates: false, + failEmulation: false, deferRegistration: false, settleRegistration(reject = false) { if (!pendingRegistration) throw new Error("No pending registration") @@ -29,6 +30,7 @@ export const nativeFixture = { async function invoke(command: string, args: any = {}) { calls.push({ command, ...args }) if (command === "plugin:event|listen") return args.handler + if (command === "browser_target_action" && args.payload.action === "emulate" && nativeFixture.failEmulation) throw new Error("Emulation failed") if (command === "browser_target_register" && nativeFixture.deferRegistration) { return new Promise((resolve, reject) => { pendingRegistration = { resolve, reject } }) } @@ -59,5 +61,6 @@ if (host === "electron") { Object.assign(window, { electronAPI: { registerBrowserTarget: (payload: unknown) => invoke("browser_target_register", { payload }), unregisterBrowserTarget: (registrationId: string) => invoke("browser_target_unregister", { registrationId }), + emulateBrowserTarget: (registrationId: string, preset: string) => invoke("browser_target_action", { registrationId, payload: { action: "emulate", preset } }), } }) } diff --git a/packages/ui/tests/browser/fixtures/browser-frame-native.tsx b/packages/ui/tests/browser/fixtures/browser-frame-native.tsx index 3fe256385..b110018a5 100644 --- a/packages/ui/tests/browser/fixtures/browser-frame-native.tsx +++ b/packages/ui/tests/browser/fixtures/browser-frame-native.tsx @@ -2,23 +2,33 @@ import { nativeFixture } from "./browser-frame-native-bridge" import { Show, createSignal } from "solid-js" import { render } from "solid-js/web" import { BrowserFrame } from "../../../src/components/browser-frame" +import { ConfigProvider } from "../../../src/stores/preferences" +import { I18nProvider } from "../../../src/lib/i18n" +import { serverApi } from "../../../src/lib/api-client" import "../../../src/index.css" +serverApi.fetchConfigOwner = async () => ({ settings: { locale: "en" } }) as any +serverApi.fetchStateOwner = async () => ({}) as any + function Fixture() { const [mounted, setMounted] = createSignal(true) const [overlay, setOverlay] = createSignal(false) const [error, setError] = createSignal("") + const [sessionId, setSessionId] = createSignal("fixture-session") const query = new URLSearchParams(location.search) const guestQuery = query.has("redirect") ? "?redirect" : query.has("hold") ? "?hold" : "" const [address, setAddress] = createSignal(`${location.origin}/browser-native-guest${guestQuery}`) - Object.assign(nativeFixture, { mount: setMounted, overlay: setOverlay, address: setAddress }) + Object.assign(nativeFixture, { mount: setMounted, overlay: setOverlay, address: setAddress, session: setSessionId }) return <>
- setMounted(false)} initialUrl={address()} initialAddress={address()} proxyBasePath="" addressMode="url" - labels={{ back: "Back", refresh: "Refresh", path: "Address", go: "Go", viewport: "Viewport" }} + labels={{ back: "Back", refresh: "Refresh", path: "Address", go: "Go", viewport: "Viewport", + viewportResponsive: "Responsive", viewportDesktop: "Desktop (1440 × 900)", + viewportTablet: "Tablet (768 × 1024)", viewportTabletLandscape: "Tablet landscape (1024 × 768)", + viewportMobile: "Mobile (390 × 844)", viewportMobileLandscape: "Mobile landscape (844 × 390)" }} onNavigate={async url => url} onFrameLocation={url => { nativeFixture.locations.push(url); setAddress(url) }} onNavigationError={reason => { @@ -32,4 +42,4 @@ function Fixture() { } -render(() => , document.getElementById("root")!) +render(() => , document.getElementById("root")!) diff --git a/packages/ui/tests/browser/fixtures/header-windows.tsx b/packages/ui/tests/browser/fixtures/header-windows.tsx index 906db7cbe..0271a0d37 100644 --- a/packages/ui/tests/browser/fixtures/header-windows.tsx +++ b/packages/ui/tests/browser/fixtures/header-windows.tsx @@ -6,10 +6,11 @@ import InstanceShell from "../../../src/components/instance/instance-shell2" import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences" import { I18nProvider } from "../../../src/lib/i18n" import { ThemeProvider } from "../../../src/lib/theme" +import { promptInputHeight } from "../../../src/components/prompt-input/height-state" import { serverApi } from "../../../src/lib/api-client" import { sdkManager } from "../../../src/lib/sdk-manager" import { addInstance, instances } from "../../../src/stores/instances" -import { setSessions, setActiveSession, setActiveParentSession, setSessionPage, setProviders, setSessionStatus, activeSessionId } from "../../../src/stores/session-state" +import { setSessions, setActiveSession, setActiveParentSession, setSessionPage, setProviders, setSessionStatus, activeSessionId, setSessionInfoByInstance } from "../../../src/stores/session-state" import { ensureWorktreesLoaded } from "../../../src/stores/worktrees" import "../../../src/index.css" @@ -71,8 +72,13 @@ await updatePreferences({ locale: "en" }) setPreferences: updatePreferences, executions: () => executions, showInfo: () => setActiveSession(id, "info"), + showSession: () => setActiveSession(id, sessionId), + setContext: (used: number, available: number) => setSessionInfoByInstance(previous => new Map(previous).set(id, + new Map([[sessionId, { actualUsageTokens: used, contextAvailableTokens: available } as any]]))), setLocale: (locale: "en" | "he") => updatePreferences({ locale }), setWorking: () => setSessionStatus(id, sessionId, "working", { force: true }), + setIdle: () => setSessionStatus(id, sessionId, "idle", { force: true }), escapeStates: () => escapeStates, interrupts: () => interrupts, + promptHeight: promptInputHeight, } diff --git a/packages/ui/tests/browser/header-windows.test.ts b/packages/ui/tests/browser/header-windows.test.ts index 1776f0990..cdcca3635 100644 --- a/packages/ui/tests/browser/header-windows.test.ts +++ b/packages/ui/tests/browser/header-windows.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { after, before, test } from "node:test" import { fileURLToPath } from "node:url" -import { chromium, type Browser } from "playwright" +import { chromium, devices, type Browser } from "playwright" import { createServer, type ViteDevServer } from "vite" import solid from "vite-plugin-solid" @@ -11,7 +11,7 @@ before(async () => { plugins: [solid(), { name: "header-windows-fixture", configureServer(s) { s.middlewares.use("/fixture", async (_req, res) => { res.setHeader("Content-Type", "text/html") - res.end(await s.transformIndexHtml("/fixture", '
')) + res.end(await s.transformIndexHtml("/fixture", '
')) }) } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, @@ -22,6 +22,310 @@ before(async () => { }) after(async () => { await browser?.close(); await server?.close() }) +for (const touch of [false, true]) for (const [width, height] of [[320, 740], [360, 740], [390, 844], [430, 932], [932, 430]]) { + test(`composer controls fit ${width}px ${touch ? "touch" : "mouse"} with a preserved draft`, async () => { + const page = await browser.newPage({ ...(touch ? devices["Pixel 5"] : {}), viewport: { width, height } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.evaluate(() => (window as any).fixture.setLocale("fr")) + const input = page.locator("textarea.prompt-input") + await input.fill("Brouillon mobile conservé") + const geometry = await page.evaluate(() => { + const footer = document.querySelector(".prompt-input-footer")!.getBoundingClientRect() + const buttons = [...document.querySelectorAll(".prompt-context-controls .selector-trigger, .prompt-input-footer-actions button")] + .filter(el => el.getBoundingClientRect().width > 0).map(el => { + const r = el.getBoundingClientRect() + return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height } + }) + return { footer: { x: footer.x, right: footer.right, bottom: footer.bottom, width: footer.width }, buttons, + selectors: [...document.querySelectorAll(".prompt-context-controls .selector-trigger")].map(el => el.getBoundingClientRect().y), + actionsY: document.querySelector(".prompt-input-footer-actions")!.getBoundingClientRect().y } + }) + for (const b of geometry.buttons) { + assert.ok(b.x >= geometry.footer.x - 1 && b.right <= geometry.footer.right + 1 && b.bottom <= geometry.footer.bottom + 1, JSON.stringify(b)) + if (touch) assert.ok(b.width >= 32 && b.height >= 32, "dense touch controls retain compact targets") + } + for (let i = 0; i < geometry.buttons.length; i++) for (const b of geometry.buttons.slice(i + 1)) { + const a = geometry.buttons[i] + assert.ok(a.right <= b.x + 1 || b.right <= a.x + 1 || a.bottom <= b.y + 1 || b.bottom <= a.y + 1, "controls must not overlap") + } + assert.equal(new Set(geometry.selectors).size, 1, "selectors retain one compact row") + assert.ok(Math.abs(geometry.actionsY - geometry.selectors[0]) <= 1, "actions and selectors always share one row") + if (width === 390) { + const worktree = page.locator('.prompt-context-controls[data-has-worktree="true"] > .sidebar-selector').first().getByRole("button") + assert.match(await worktree.innerText(), /Espace de travail/) + await worktree.focus() + await page.keyboard.press("ArrowDown") + await page.getByRole("listbox").waitFor({ state: "visible" }) + await page.keyboard.press("Escape") + await page.getByRole("listbox").waitFor({ state: "hidden" }) + assert.equal(await input.inputValue(), "Brouillon mobile conservé") + } + if (process.env.CODENOMAD_MOBILE_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_MOBILE_CAPTURE}/${touch ? "touch" : "mouse"}-${width}.png`, scale: "css" }) + await page.setViewportSize({ width: 600, height: 851 }) + assert.equal(await input.inputValue(), "Brouillon mobile conservé") + } finally { await page.close() } + }) +} + +for (const device of ["Pixel 5", "iPhone 13", "Desktop Chrome"] as const) test(`timeline visibility follows conversation width on ${device}, independently of header density and height`, async () => { + const page = await browser.newPage({ ...devices[device], viewport: { width: 1100, height: 1000 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: true })) + await page.getByText("Fixture message", { exact: true }).waitFor() + const timeline = page.locator(".message-timeline-sidebar") + await timeline.waitFor({ state: "attached" }) + // Constrain the real conversation container independently of the viewport + // so docked sidebars and narrow desktop panes use the same breakpoint. + await page.addStyleTag({ content: ".session-center-column { flex: 0 0 var(--fixture-center-width, 390px) !important; min-width: 0 !important; }" }) + for (const [width, height] of [[390, 844], [419, 844], [420, 844], [459, 844], [460, 844], [844, 390], [390, 400], [430, 932]]) { + await page.locator(".session-center-column").evaluate((el, width) => (el as HTMLElement).style.setProperty("--fixture-center-width", `${width}px`), width) + const expected = width >= 420 + await page.setViewportSize({ width, height }) + await page.evaluate(() => (window as any).fixture.setWorking()) + for (const density of [0, 4]) { + const result = await page.locator(".session-center-column").evaluate((el, density) => { + el.setAttribute("data-session-header-density", String(density)) + const rail = el.querySelector(".message-timeline-sidebar")! + const view = el.querySelector(".session-view")! + const worktree = el.querySelector('.prompt-context-controls[data-has-worktree="true"] > .sidebar-selector:first-child .selector-trigger')! + const label = worktree.querySelector("div")! + return { width: el.getBoundingClientRect().width, visible: rail.getBoundingClientRect().width > 0, + worktreeWidth: worktree.getBoundingClientRect().width, worktreeLabelHidden: getComputedStyle(label).position === "absolute", + padding: parseFloat(getComputedStyle(view).paddingInlineEnd), + footer: getComputedStyle(el.querySelector(".prompt-input-footer")!, "::after").display } + }, density) + assert.equal(Math.round(result.width), width) + assert.equal(result.visible, expected, JSON.stringify({ width, height, density, result })) + assert.equal(result.padding > 0, expected, "hidden timeline leaves no reserved rail") + assert.equal(result.worktreeLabelHidden, width < 460, "worktree label uses its own conversation-width breakpoint") + if (width < 460) assert.equal(result.worktreeWidth, 32, "arrow-only worktree frees room for other selectors") + if (!expected) assert.equal(result.footer, "none", "hidden rail leaves no footer extension") + } + } + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: false })) + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: true })) + assert.equal(await timeline.isVisible(), true) + } finally { await page.close() } +}) + +test("short landscape composer shrinks, scrolls long drafts and shares pointer/keyboard resize limits", async () => { + const page = await browser.newPage({ viewport: { width: 1800, height: 900 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + const input = page.locator("textarea.prompt-input") + const original = await input.evaluate(el => el.getBoundingClientRect().height) + await page.setViewportSize({ width: 1800, height: 390 }) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height < height, original) + const draft = Array.from({ length: 20 }, (_, i) => `Line ${i}`).join("\n") + await input.fill(draft) + await page.waitForFunction(() => { + const el = document.querySelector("textarea.prompt-input")! + return el.clientHeight < 104 && el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY === "auto" + }) + const resize = page.locator(".prompt-resize-handle") + await resize.focus() + await page.keyboard.press("Home") + const minimum = Number(await resize.getAttribute("aria-valuemin")) + assert.equal(Number(await resize.getAttribute("aria-valuenow")), minimum) + const bounds = await resize.boundingBox() + assert.ok(bounds) + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) + await page.mouse.down() + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + 80) + await page.mouse.up() + assert.equal(Number(await resize.getAttribute("aria-valuenow")), minimum) + assert.equal(await input.inputValue(), draft) + } finally { await page.close() } +}) + +test("manual prompt height follows the pointer without changing its minimum as the draft grows", async () => { + const page = await browser.newPage({ viewport: { width: 701, height: 1275 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + const input = page.locator("textarea.prompt-input") + const resize = page.locator(".prompt-resize-handle") + const minimum = await page.locator(".session-center-column").evaluate(el => Math.max(44, Math.floor(el.getBoundingClientRect().height * 0.08))) + await resize.focus() + await page.keyboard.press("Home") + await page.keyboard.press("ArrowUp") + const chosen = Number(await resize.getAttribute("aria-valuenow")) + await input.fill(Array.from({ length: 20 }, (_, i) => `Draft line ${i}`).join("\n")) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + assert.equal(await input.evaluate(el => el.getBoundingClientRect().height), chosen, "typing must not raise a manually chosen height") + const bounds = await resize.boundingBox() + assert.ok(bounds) + const x = bounds.x + bounds.width / 2, y = bounds.y + bounds.height / 2 + await page.mouse.move(x, y) + await page.mouse.down() + for (const delta of [20, 80, 40, -80]) { + await page.mouse.move(x, y - delta) + const expected = Math.max(minimum, chosen + delta) + await page.waitForFunction(expected => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height === expected, expected) + } + await page.mouse.up() + assert.equal(Number(await resize.getAttribute("aria-valuemin")), minimum, "a tall narrow window uses the same proportional rule") + assert.equal(await input.evaluate(el => getComputedStyle(el).overflowY), "auto") + } finally { await page.close() } +}) + +test("chosen prompt proportion follows window height and survives composer remounts", async () => { + const page = await browser.newPage({ viewport: { width: 701, height: 900 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + const input = page.locator("textarea.prompt-input") + const resize = page.locator(".prompt-resize-handle") + await input.fill("Keep my draft while resizing") + await resize.focus() + await page.keyboard.press("Home") + for (let i = 0; i < 9; i++) await page.keyboard.press("ArrowUp") + const saved = await page.evaluate(() => (window as any).fixture.promptHeight()) + assert.ok(saved.ratio > 0.2 && saved.ratio < 0.4, `choose a height away from both bounds: ${saved.ratio}`) + for (const height of [650, 1100, 390, 1275, 900]) { + await page.setViewportSize({ width: 701, height }) + await page.waitForFunction(ratio => { + const available = document.querySelector(".session-center-column")!.getBoundingClientRect().height + const actual = document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height + return actual === Math.max(44, Math.round(available * ratio)) + }, saved.ratio) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + } + const before = await input.evaluate(el => el.getBoundingClientRect().height) + await page.evaluate(() => (window as any).fixture.showInfo()) + await page.evaluate(() => (window as any).fixture.showSession()) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")?.getBoundingClientRect().height === height, before) + assert.equal(await input.inputValue(), "Keep my draft while resizing") + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + } finally { await page.close() } +}) + +test("visual keyboard shrink clamps the displayed manual height without overwriting it", async () => { + const page = await browser.newPage({ viewport: { width: 932, height: 900 } }) + await page.addInitScript(`(() => { + let height = window.innerHeight + Object.defineProperty(window.visualViewport, "height", { get: () => height }) + window.setKeyboardViewport = (next) => { + height = next + window.visualViewport.dispatchEvent(new Event("resize")) + } + })()`) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + const input = page.locator("textarea.prompt-input") + await input.fill("Draft stays here") + await page.locator(".prompt-resize-handle").focus() + await page.keyboard.press("End") + const saved = await page.evaluate(() => (window as any).fixture.promptHeight()) + const original = await input.evaluate(el => el.getBoundingClientRect().height) + assert.ok(original > 104) + await page.evaluate(() => (window as any).setKeyboardViewport(320)) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height < height, original) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + assert.equal(await page.evaluate(() => innerHeight), 900, "keyboard changes only the visual viewport") + await page.evaluate(() => (window as any).setKeyboardViewport(900)) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height === height, original) + assert.equal(await input.inputValue(), "Draft stays here") + } finally { await page.close() } +}) + +test("context text never overlaps header actions after usage and session changes", async () => { + const page = await browser.newPage({ ...devices["Pixel 5"], viewport: { width: 390, height: 844 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.evaluate(() => (window as any).fixture.setLocale("fr")) + for (const width of [390, 420, 460, 360]) { + await page.setViewportSize({ width, height: 844 }) + for (const used of [0, 14000, 272000]) for (const working of [true, false]) { + await page.evaluate(used => { + const f = (window as any).fixture + f.showInfo() + f.setContext(used, 272000) + f.showSession() + }, used) + await page.evaluate(working => working ? (window as any).fixture.setWorking() : (window as any).fixture.setIdle(), working) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))) + const geometry = await page.evaluate(() => { + const meter = document.querySelector(".context-meter")! + const actions = document.querySelector(".session-header-actions-slot")!.getBoundingClientRect() + const rects = [...meter.querySelectorAll("span, svg")].map(el => el.getBoundingClientRect()).filter(r => r.width > 0) + const indicators = document.querySelector(".session-header-indicators")!.getBoundingClientRect() + return { right: Math.max(...rects.map(r => r.right)), left: actions.left, + indicators: { left: indicators.left, right: indicators.right, width: indicators.width }, + density: document.querySelector(".session-center-column")!.getAttribute("data-session-header-density") } + }) + assert.ok(geometry.right + 4 <= geometry.left, JSON.stringify({ width, used, geometry })) + if (geometry.indicators.width) assert.ok(geometry.right + 4 <= geometry.indicators.left && geometry.indicators.right + 4 <= geometry.left, JSON.stringify({ width, used, working, geometry })) + } + } + } finally { await page.close() } +}) + +test("desktop timeline still follows its saved visibility preference", async () => { + const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: true })) + const timeline = page.locator(".message-timeline-sidebar") + await timeline.waitFor({ state: "visible" }) + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: false })) + await timeline.waitFor({ state: "detached" }) + await page.evaluate(() => (window as any).fixture.setPreferences({ showMessageTimeline: true })) + await timeline.waitFor({ state: "visible" }) + } finally { await page.close() } +}) + +test("content filters join the measured header overflow and restore keyboard focus", async () => { + const page = await browser.newPage({ viewport: { width: 280, height: 844 }, hasTouch: true }) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.evaluate(() => (window as any).fixture.setWorking()) + const menu = page.locator(".session-header-actions-menu") + await menu.waitFor({ state: "visible" }) + assert.equal(await page.locator(".transcript-filters-trigger").isVisible(), false) + await menu.click() + await page.getByRole("menuitem", { name: "Message content", exact: true }).click() + const filters = page.locator(".transcript-filters") + await filters.waitFor() + const bounds = await filters.boundingBox() + assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 281, "filters stay inside the narrow viewport") + await page.keyboard.press("Escape") + await filters.waitFor({ state: "hidden" }) + await page.waitForFunction(() => document.activeElement?.classList.contains("session-header-actions-menu")) + await page.setViewportSize({ width: 1800, height: 1000 }) + const direct = page.locator(".transcript-filters-trigger") + await direct.waitFor({ state: "visible" }) + await direct.click() + await filters.waitFor() + await page.locator("#outside").click() + await filters.waitFor({ state: "hidden" }) + assert.deepEqual(errors, []) + } catch (error) { + console.error({ errors, body: await page.locator("body").innerText() }) + throw error + } finally { await page.close() } +}) + for (const kind of ["command-palette", "session-search"]) test(`${kind} stays open outside, marks its toggle, and closes explicitly`, async () => { const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } }) const errors: string[] = []