fix(mobile): responsive conversation controls and proportional prompt sizing (#813)

## Summary
- Improve mobile conversation controls for #800: one-row truncated
composer controls, compact touch actions, header overflow for transcript
filters and session-change header measurement rebinding.
- Hide the timeline below 420px and reduce the worktree selector to its
accessible arrow below 460px of conversation width; preserve
preferences.
- Make the prompt itself follow visible conversation height with a saved
manual proportion, 8% minimum / 60% maximum and a 44px floor. Keep
drafts and migrate legacy pixel preferences.
- Preserve the original six preview templates and add owned native
Android/Chromium touch emulation to mobile templates in Electron and
Windows Tauri. Allow main windows to shrink to 390px.

## Validation
- UI typecheck and build passed.
- Focused browser regressions cover narrow mouse/touch layouts,
thresholds, filters, draft preservation, pointer/keyboard resizing,
proportional window resizing and visual-keyboard changes.
- Native Electron emulation and controller/window tests and Tauri
controller/restoration tests exercised during implementation; final
verification and independent Gatekeeper review will be posted below.

## Scope and limitations
- Emulation is Android/Chromium, not a claim of Gecko/Waterfox or Safari
parity; iframe fallback changes only dimensions. Physical software
keyboard behavior is represented with deterministic visualViewport
fixtures.
- No shared daemon restart, profile reset or unrelated main-checkout
changes.
- Existing large files touched: `prompt-input.tsx` (~1068 lines),
`instance-shell2.tsx` (~1325), `session-view.tsx` (~697),
`browser-frame.tsx` (~877).

Refs #800.
This commit is contained in:
Pascal André 2026-10-01 01:04:25 +02:00 • committed by GitHub
parent db1f552afd
commit 0822c794ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1180 additions and 183 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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<void>(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<void>(resolve => { release = resolve })
let entered!: () => void
const started = new Promise<void>(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

View file

@ -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<void> {
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<T>(guest: WebContents, deadline: number, operation: (debuggerSession: WebContents["debugger"]) => Promise<T>): Promise<T> {
async function withDebugger<T>(guest: WebContents, deadline: number, operation: (debuggerSession: Pick<WebContents["debugger"], "sendCommand">) => Promise<T>): Promise<T> {
const attached = guest.debugger.isAttached()
if (!attached) guest.debugger.attach("1.3")
const debuggerSession = {
sendCommand: (...args: Parameters<WebContents["debugger"]["sendCommand"]>) => {
// 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()

View file

@ -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<WebContents>()
const currentProfiles = new WeakMap<WebContents, string>()
export async function setBrowserEmulation(guest: WebContents, preset: unknown): Promise<void> {
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()
}
}

View file

@ -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)

View file

@ -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

View file

@ -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", () => {

View file

@ -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

View file

@ -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),

View file

@ -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<String>,
preset: Option<String>,
}
#[derive(Clone)]
@ -69,6 +74,7 @@ struct OpenClaim {
#[derive(Default)]
struct Inner {
emulation_profiles: HashMap<String, String>,
registrations: HashMap<String, Registration>,
renderer_versions: HashMap<String, u64>,
refs: HashMap<String, HashMap<String, i64>>,
@ -80,6 +86,7 @@ struct Inner {
#[derive(Clone)]
pub(crate) struct BrowserController {
emulation_lock: Arc<Mutex<()>>,
inner: Arc<(Mutex<Inner>, Condvar)>,
profile: Arc<std::path::PathBuf>,
registration_sequence: Arc<AtomicU64>,
@ -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(&registration.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);

View file

@ -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(&registration.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<Option<(&str, bool)>, 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)))
}

View file

@ -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;

View file

@ -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]

View file

@ -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;

View file

@ -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)

View file

@ -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() {

View file

@ -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<BrowserViewportPreset, { width: number | null; he
desktop: { width: 1440, height: 900 },
tablet: { width: 768, height: 1024 },
tabletLandscape: { width: 1024, height: 768 },
mobile: { width: 390, height: 844 },
mobileLandscape: { width: 844, height: 390 },
mobile: emulationProfiles.mobile,
mobileLandscape: { width: emulationProfiles.mobile.height, height: emulationProfiles.mobile.width },
}
const VIEWPORT_OPTIONS = [
@ -110,6 +114,9 @@ function getElementSelector(element: Element): string {
}
export const BrowserFrame: Component<BrowserFrameProps> = (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<BrowserViewportPreset>("responsive")
@ -133,6 +140,8 @@ export const BrowserFrame: Component<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (props) => {
}
registered = true
registeredSessionId = sessionId
await restoreEmulation(registrationId)
registrationFailures = 0
registrationErrorReported = false
syncRegistration()
@ -416,6 +465,7 @@ export const BrowserFrame: Component<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (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<BrowserFrameProps> = (props) => {
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div class="flex shrink-0 items-center gap-2 px-3 py-2" style={{ "border-bottom": "1px solid var(--border-base)" }}>
<div class="relative flex shrink-0 items-center gap-2 px-3 py-2" style={{ "border-bottom": "1px solid var(--border-base)" }}>
<button type="button" class="new-tab-button" onClick={handleBack} title={props.labels.back} aria-label={props.labels.back}>
<ArrowLeft class="h-4 w-4" />
</button>
@ -711,7 +763,7 @@ export const BrowserFrame: Component<BrowserFrameProps> = (props) => {
<ArrowRight class="h-4 w-4" />
</button>
</form>
<div class="relative shrink-0">
<div class="shrink-0">
<button
type="button"
class="selector-button selector-button-secondary px-2 py-1.5 text-sm"
@ -719,6 +771,7 @@ export const BrowserFrame: Component<BrowserFrameProps> = (props) => {
title={props.labels.viewport}
aria-haspopup="menu"
aria-expanded={viewportMenuOpen() ? "true" : "false"}
disabled={emulationBusy()}
onClick={() => setViewportMenuOpen((open) => !open)}
>
{(() => {
@ -729,8 +782,8 @@ export const BrowserFrame: Component<BrowserFrameProps> = (props) => {
</button>
<Show when={viewportMenuOpen()}>
<div
class="absolute right-0 top-full z-20 mt-1 min-w-[13rem] overflow-hidden border border-base shadow-xl"
style={{ background: "var(--surface-base)", color: "var(--text-primary)" }}
class="absolute right-0 top-full z-20 mt-1 min-w-[13rem] max-w-[calc(100vw-2rem)] overflow-y-auto border border-base shadow-xl"
style={{ background: "var(--surface-base)", color: "var(--text-primary)", "max-height": "min(28rem, calc(100dvh - 8rem))" }}
role="menu"
>
{VIEWPORT_OPTIONS.map((option) => {
@ -743,11 +796,11 @@ export const BrowserFrame: Component<BrowserFrameProps> = (props) => {
role="menuitemradio"
aria-checked={viewportPreset() === option.id ? "true" : "false"}
aria-label={option.getLabel(props.labels)}
title={option.getLabel(props.labels)}
onClick={() => {
setViewportPreset(option.id)
setViewportMenuOpen(false)
}}
disabled={nativeMode() && emulationProfile(option.id) !== "none" && !emulationReady()}
title={emulationProfile(option.id) !== "none"
? t(nativeMode() ? "browser.viewport.emulationHint" : "browser.viewport.emulationUnavailable")
: option.getLabel(props.labels)}
onClick={() => void selectViewport(option.id)}
>
<Icon class="h-4 w-4" />
<span>{option.getLabel(props.labels)}</span>

View file

@ -49,7 +49,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
import { registerViewMenuPanels } from "../../lib/native/view-menu"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status"
import { Command as CommandIcon, Globe, Maximize2, Search, ShieldAlert } from "lucide-solid"
import { Command as CommandIcon, Globe, ListFilter, Maximize2, Search, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import type { Attachment } from "../../types/attachment"
import { setAgentModelPreference, useConfig } from "../../stores/preferences"
@ -134,10 +134,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [sessionCenterEl, setSessionCenterEl] = createSignal<HTMLElement | null>(null)
const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal<SessionCenterWidthStep>("wide")
const [headerDensity, setHeaderDensity] = createSignal(0)
const [filtersOpen, setFiltersOpen] = createSignal(false)
let sessionToolbarEl: HTMLElement | undefined
let headerLeftEl: HTMLElement | undefined
let headerRightEl: HTMLElement | undefined
let headerIndicatorsEl: HTMLElement | undefined
const [headerIndicatorsEl, setHeaderIndicatorsEl] = createSignal<HTMLElement>()
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
let lastAutoOpenedFormId: string | null = null
@ -193,7 +194,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode)
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const { setDrawerHost, drawerContainer, drawerHostWidth, measureDrawerHost } = useDrawerHostMeasure()
@ -323,11 +323,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
createEffect(() => {
if (!props.isActiveInstance || mobileFullscreen() || typeof ResizeObserver === "undefined") return
// Session/status changes can replace this subtree. Rebind the observer to
// the current node instead of continuing to observe a detached indicator.
const indicators = headerIndicatorsEl()
if (!indicators) return
let frame = 0
const measure = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
if (!sessionToolbarEl || !headerLeftEl || !headerRightEl || !headerIndicatorsEl) return
if (!sessionToolbarEl || !headerLeftEl || !headerRightEl) return
const gap = 8
let density = 4
@ -335,7 +339,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sessionCenterEl()?.setAttribute("data-session-header-density", String(candidate))
const leftRect = headerLeftEl.getBoundingClientRect()
const rightRect = headerRightEl.getBoundingClientRect()
const indicatorsRect = headerIndicatorsEl.getBoundingClientRect()
const indicatorsRect = indicators.getBoundingClientRect()
const sidesFit = leftRect.right + gap <= rightRect.left
const indicatorsFit = indicatorsRect.width === 0 ||
(indicatorsRect.left >= leftRect.right + gap && indicatorsRect.right <= rightRect.left - gap)
@ -348,7 +352,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
}
const observer = new ResizeObserver(measure)
;[sessionToolbarEl, headerLeftEl, headerRightEl, headerIndicatorsEl]
;[sessionToolbarEl, headerLeftEl, headerRightEl, indicators]
.forEach((element) => element && observer.observe(element))
measureDrawerHost()
measure()
@ -575,7 +579,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
const renderSessionHeaderIndicators = () => (
<div ref={(element) => { headerIndicatorsEl = element }} class="session-header-indicators flex items-center justify-center gap-2">
<div ref={setHeaderIndicatorsEl} class="session-header-indicators flex items-center justify-center gap-2">
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
@ -626,6 +630,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const searchWindowId = () => sessionSearchWindowId(props.instance.id, activeSessionIdForInstance() ?? "")
const paletteWindowId = () => `command-palette-${props.instance.id}`
createEffect(() => {
activeSessionIdForInstance()
props.isActiveInstance
setFiltersOpen(false)
})
const headerActionMenuItems = (): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = [{
key: "commands",
@ -652,6 +662,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
: <Globe class="w-4 h-4" aria-hidden="true" />,
onSelect: runtimeEnv.platform === "mobile" ? props.onEnterMobileFullscreen : handlePreviewButtonClick,
},
{
key: "content",
label: t("transcriptFilters.title"),
icon: <ListFilter class="w-4 h-4" aria-hidden="true" />,
onSelect: () => { setFiltersOpen(true) },
},
)
return items
}
@ -1094,6 +1110,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Search class="w-5 h-5" aria-hidden="true" />
</IconButton>
{renderHeaderThirdActionButton()}
<TranscriptFilters open={filtersOpen()} onOpenChange={setFiltersOpen}
overflowAnchor={() => headerDensity() === 4
? sessionToolbarEl?.querySelector<HTMLElement>(".session-header-actions-menu") ?? undefined
: undefined} />
</Show>
</div>
<ActionOverflowMenu
@ -1120,9 +1140,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
</span>
</Show>
<Show when={!showingInfoView()}>
<div class="panel-header-actions"><TranscriptFilters /></div>
</Show>
</div>
</div>
</div>
@ -1201,7 +1218,6 @@ const InstanceShell2: Component<InstanceShellProps> = (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<InstanceShellProps> = (props) => {
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
focusConversationOnActivate={focusConversationSessionId() === sessionId}
onConversationFocusHandled={() => {
if (focusConversationSessionId() === sessionId) setFocusConversationSessionId(null)

View file

@ -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<PromptMode>("normal")
const inputHeight = promptInputHeight
const setInputHeight = setPromptInputHeight
const [autoInputHeight, setAutoInputHeight] = createSignal<number | null>(null)
const [isResizing, setIsResizing] = createSignal(false)
const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal<SessionCenterWidthStep | null>(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<PromptInputApi["restoreQueuedPrompt"]>[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"

View file

@ -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,

View file

@ -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<number | null>(null)
// Numbers are legacy pixel heights, converted once the composer is measured.
type PromptInputHeight = number | { ratio: number } | null
const [promptInputHeight, setPromptInputHeightValue] = createSignal<PromptInputHeight>(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 }

View file

@ -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<void>
onCommand?: (commandName: string, args: string) => Promise<void>
onRunShell?: (command: string) => Promise<void>

View file

@ -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
}

View file

@ -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<SessionViewProps> = (props) => {
instanceFolder={session()?.location.directory ?? props.instanceFolder}
sessionId={props.sessionId}
isActive={props.isActive}
compactLayout={props.compactPromptLayout}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}

View file

@ -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 (
<Popover placement="bottom-end" gutter={6}>
<Popover placement="bottom-end" gutter={6} open={props.open} onOpenChange={props.onOpenChange}
getAnchorRect={(anchor) => (props.overflowAnchor?.() ?? anchor)?.getBoundingClientRect()}>
<Popover.Trigger as={IconButton} size="small" color="inherit" class="transcript-filters-trigger icon-toggle" aria-label={t("transcriptFilters.title")} title={t("transcriptFilters.title")}>
<ListFilter class="w-4 h-4" aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="window-shell transcript-filters" aria-busy={saving()}>
<Popover.Content class="window-shell transcript-filters" aria-busy={saving()} onCloseAutoFocus={(event) => {
const anchor = props.overflowAnchor?.()
if (anchor) { event.preventDefault(); anchor.focus() }
}}>
<header class="window-header">
<Popover.Title class="window-title">{t("transcriptFilters.title")}</Popover.Title>
</header>

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -1,4 +1,6 @@
export const sessionMessages = {
"browser.viewport.emulationHint": "טוען מחדש עם זיהוי נייד, מגע וצפיפות פיקסלים. מנוע הדפדפן, המקלדת וסרגלי המערכת אינם מדומים.",
"browser.viewport.emulationUnavailable": "הדמיית מכשיר דורשת תצוגה מקדימה מקורית של האינטרנט ביישום למחשב.",
"sessionList.filter.includeMainSessions": "הצגת סשנים ראשיים",
"sessionList.filter.includeSubsessions": "הצגת תת־סשנים",
"session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.",

View file

@ -1,4 +1,6 @@
export const sessionMessages = {
"browser.viewport.emulationHint": "モバイル識別情報、タッチ、ピクセル密度を設定して再読み込みします。ブラウザーエンジン、キーボード、システムバーは再現しません。",
"browser.viewport.emulationUnavailable": "デバイスのエミュレーションにはデスクトップアプリのネイティブ Web プレビューが必要です。",
"sessionList.filter.includeMainSessions": "メインセッションを表示",
"sessionList.filter.includeSubsessions": "サブセッションを表示",
"session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。",

View file

@ -1,4 +1,6 @@
export const sessionMessages = {
"browser.viewport.emulationHint": "मोबाइल पहिचान, टच र पिक्सेल घनत्वसहित पुनः लोड गर्छ। ब्राउजर इन्जिन, किबोर्ड र प्रणाली बारहरू अनुकरण गरिँदैनन्।",
"browser.viewport.emulationUnavailable": "उपकरण अनुकरणका लागि डेस्कटप एपको नेटिभ वेब पूर्वावलोकन चाहिन्छ।",
"sessionList.filter.includeMainSessions": "मुख्य सत्रहरू देखाउनुहोस्",
"sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्",
"session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।",

View file

@ -1,4 +1,6 @@
export const sessionMessages = {
"browser.viewport.emulationHint": "Перезагрузка с мобильной идентификацией, сенсорным вводом и плотностью пикселей. Движок браузера, клавиатура и системные панели не эмулируются.",
"browser.viewport.emulationUnavailable": "Для эмуляции устройства нужен нативный веб-предпросмотр в настольном приложении.",
"sessionList.filter.includeMainSessions": "Показывать основные сессии",
"sessionList.filter.includeSubsessions": "Показывать подсессии",
"session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.",

View file

@ -1,4 +1,6 @@
export const sessionMessages = {
"browser.viewport.emulationHint": "使用移动设备标识、触控和像素密度重新加载。不模拟浏览器引擎、键盘或系统栏。",
"browser.viewport.emulationUnavailable": "设备模拟需要桌面应用中的原生网页预览。",
"sessionList.filter.includeMainSessions": "显示主会话",
"sessionList.filter.includeSubsessions": "显示子会话",
"session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。",

View file

@ -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"
}

View file

@ -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,
}
}

View file

@ -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<void> {
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> {

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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";

View file

@ -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);
}
}

View file

@ -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;

View file

@ -71,6 +71,7 @@ declare global {
clearClientState?: (accessToken: string) => Promise<boolean>
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<void>
claimBrowserOpen?: (requestID: string) => Promise<boolean>
releaseBrowserOpen?: (requestID: string) => Promise<boolean>
onBrowserOpenRequest?: (callback: (payload: { sessionID: string; url: string; requestID: string }) => void) => () => void

View file

@ -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('<html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body>Mobile fixture</body></html>')
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
let app: Awaited<ReturnType<typeof _electron.launch>> | 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<void>(resolve => server.close(() => resolve()))
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})

View file

@ -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())

View file

@ -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())

View file

@ -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<void>((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 } }),
} })
}

View file

@ -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 <>
<div id="preview" style={{ width: "800px", height: "500px" }}>
<Show when={mounted()}>
<BrowserFrame sessionId="fixture-session" title="Preview"
<BrowserFrame sessionId={sessionId()} title="Preview" onClose={() => 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(() => <Fixture />, document.getElementById("root")!)
render(() => <ConfigProvider><I18nProvider><Fixture /></I18nProvider></ConfigProvider>, document.getElementById("root")!)

View file

@ -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,
}

View file

@ -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", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/header-windows.tsx"></script></body></html>'))
res.end(await s.transformIndexHtml("/fixture", '<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/header-windows.tsx"></script></body></html>'))
})
} }], 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<HTMLElement>(".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<void>(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<void>(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[] = []