mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
db1f552afd
commit
0822c794ff
53 changed files with 1180 additions and 183 deletions
|
|
@ -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.
|
||||
|
|
|
|||
46
dev-docs/BROWSER_DEVICE_EMULATION.md
Normal file
46
dev-docs/BROWSER_DEVICE_EMULATION.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
55
packages/electron-app/electron/main/browser-emulation.ts
Normal file
55
packages/electron-app/electron/main/browser-emulation.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(®istration.webview_label)
|
||||
.ok_or_else(|| "Browser preview is no longer available".to_string())?;
|
||||
match input.action.as_str() {
|
||||
"emulate" => self.emulate(app, window_label, &input),
|
||||
"back" => webview
|
||||
.eval("history.back()")
|
||||
.map_err(|error| error.to_string()),
|
||||
|
|
@ -334,6 +343,7 @@ impl BrowserController {
|
|||
return Err("Browser target belongs to another window".to_string());
|
||||
}
|
||||
let registration = inner.registrations.remove(registration_id).unwrap();
|
||||
inner.emulation_profiles.remove(registration_id);
|
||||
inner.refs.remove(registration_id);
|
||||
inner.navigation_versions.remove(registration_id);
|
||||
inner.page_load_expectations.remove(registration_id);
|
||||
|
|
|
|||
114
packages/tauri-app/src-tauri/src/browser_emulation.rs
Normal file
114
packages/tauri-app/src-tauri/src/browser_emulation.rs
Normal 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(®istration.webview_label)
|
||||
.ok_or_else(|| "Browser preview is no longer available".to_string())?;
|
||||
let previous = self
|
||||
.inner
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.emulation_profiles
|
||||
.get(&input.registration_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "none".into());
|
||||
if previous == preset {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(error) = apply(&webview, preset) {
|
||||
apply(&webview, &previous)?;
|
||||
return Err(error);
|
||||
}
|
||||
let current = self.owned_registration(owner, &input.registration_id)?;
|
||||
if current.generation != registration.generation {
|
||||
return Err("Browser target changed before emulation".into());
|
||||
}
|
||||
self.inner
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.emulation_profiles
|
||||
.insert(input.registration_id.clone(), preset.into());
|
||||
webview.reload().map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(webview: &tauri::Webview, preset: &str) -> Result<(), String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let send = |method, params| cdp(webview, method, params, deadline).map(|_| ());
|
||||
if preset == "none" {
|
||||
send("Emulation.clearDeviceMetricsOverride", json!({}))?;
|
||||
send(
|
||||
"Emulation.setTouchEmulationEnabled",
|
||||
json!({ "enabled": false }),
|
||||
)?;
|
||||
send(
|
||||
"Emulation.setEmitTouchEventsForMouse",
|
||||
json!({ "enabled": false }),
|
||||
)?;
|
||||
return send("Emulation.setUserAgentOverride", json!({ "userAgent": "" }));
|
||||
}
|
||||
let profiles: Value = serde_json::from_str(include_str!(
|
||||
"../../../ui/src/lib/native/browser-emulation.json"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (size, landscape) = profile(preset)?.unwrap();
|
||||
let dimensions = &profiles[size];
|
||||
let width = &dimensions[if landscape { "height" } else { "width" }];
|
||||
let height = &dimensions[if landscape { "width" } else { "height" }];
|
||||
let version = cdp(webview, "Browser.getVersion", json!({}), deadline)?;
|
||||
let full_version = version["product"]
|
||||
.as_str()
|
||||
.and_then(|v| v.rsplit('/').next())
|
||||
.ok_or_else(|| "Chromium version is unavailable".to_string())?;
|
||||
send(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
json!({
|
||||
"width": width, "height": height, "deviceScaleFactor": profiles["deviceScaleFactor"],
|
||||
"mobile": true, "screenWidth": width, "screenHeight": height,
|
||||
"screenOrientation": { "type": if landscape { "landscapePrimary" } else { "portraitPrimary" }, "angle": if landscape { 90 } else { 0 } }
|
||||
}),
|
||||
)?;
|
||||
send(
|
||||
"Emulation.setTouchEmulationEnabled",
|
||||
json!({ "enabled": true, "maxTouchPoints": 5 }),
|
||||
)?;
|
||||
send(
|
||||
"Emulation.setEmitTouchEventsForMouse",
|
||||
json!({ "enabled": true, "configuration": "mobile" }),
|
||||
)?;
|
||||
send(
|
||||
"Emulation.setUserAgentOverride",
|
||||
json!({
|
||||
"userAgent": profiles["userAgent"].as_str().unwrap().replace("{version}", full_version),
|
||||
"platform": "Linux armv8l",
|
||||
"userAgentMetadata": { "brands": [{ "brand": "Chromium", "version": full_version.split('.').next().unwrap() }],
|
||||
"fullVersion": full_version, "platform": "Android", "platformVersion": "11.0.0",
|
||||
"architecture": "", "model": "", "mobile": true }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn profile(preset: &str) -> Result<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)))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
41
packages/ui/src/components/prompt-input/usePromptViewport.ts
Normal file
41
packages/ui/src/components/prompt-input/usePromptViewport.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const sessionMessages = {
|
||||
"browser.viewport.emulationHint": "טוען מחדש עם זיהוי נייד, מגע וצפיפות פיקסלים. מנוע הדפדפן, המקלדת וסרגלי המערכת אינם מדומים.",
|
||||
"browser.viewport.emulationUnavailable": "הדמיית מכשיר דורשת תצוגה מקדימה מקורית של האינטרנט ביישום למחשב.",
|
||||
"sessionList.filter.includeMainSessions": "הצגת סשנים ראשיים",
|
||||
"sessionList.filter.includeSubsessions": "הצגת תת־סשנים",
|
||||
"session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const sessionMessages = {
|
||||
"browser.viewport.emulationHint": "モバイル識別情報、タッチ、ピクセル密度を設定して再読み込みします。ブラウザーエンジン、キーボード、システムバーは再現しません。",
|
||||
"browser.viewport.emulationUnavailable": "デバイスのエミュレーションにはデスクトップアプリのネイティブ Web プレビューが必要です。",
|
||||
"sessionList.filter.includeMainSessions": "メインセッションを表示",
|
||||
"sessionList.filter.includeSubsessions": "サブセッションを表示",
|
||||
"session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const sessionMessages = {
|
||||
"browser.viewport.emulationHint": "मोबाइल पहिचान, टच र पिक्सेल घनत्वसहित पुनः लोड गर्छ। ब्राउजर इन्जिन, किबोर्ड र प्रणाली बारहरू अनुकरण गरिँदैनन्।",
|
||||
"browser.viewport.emulationUnavailable": "उपकरण अनुकरणका लागि डेस्कटप एपको नेटिभ वेब पूर्वावलोकन चाहिन्छ।",
|
||||
"sessionList.filter.includeMainSessions": "मुख्य सत्रहरू देखाउनुहोस्",
|
||||
"sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्",
|
||||
"session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const sessionMessages = {
|
||||
"browser.viewport.emulationHint": "Перезагрузка с мобильной идентификацией, сенсорным вводом и плотностью пикселей. Движок браузера, клавиатура и системные панели не эмулируются.",
|
||||
"browser.viewport.emulationUnavailable": "Для эмуляции устройства нужен нативный веб-предпросмотр в настольном приложении.",
|
||||
"sessionList.filter.includeMainSessions": "Показывать основные сессии",
|
||||
"sessionList.filter.includeSubsessions": "Показывать подсессии",
|
||||
"session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const sessionMessages = {
|
||||
"browser.viewport.emulationHint": "使用移动设备标识、触控和像素密度重新加载。不模拟浏览器引擎、键盘或系统栏。",
|
||||
"browser.viewport.emulationUnavailable": "设备模拟需要桌面应用中的原生网页预览。",
|
||||
"sessionList.filter.includeMainSessions": "显示主会话",
|
||||
"sessionList.filter.includeSubsessions": "显示子会话",
|
||||
"session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。",
|
||||
|
|
|
|||
5
packages/ui/src/lib/native/browser-emulation.json
Normal file
5
packages/ui/src/lib/native/browser-emulation.json
Normal 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"
|
||||
}
|
||||
19
packages/ui/src/lib/native/browser-emulation.ts
Normal file
19
packages/ui/src/lib/native/browser-emulation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
14
packages/ui/src/styles/panels/session-toolbar-touch.css
Normal file
14
packages/ui/src/styles/panels/session-toolbar-touch.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
66
packages/ui/tests/browser/browser-emulation-native.test.ts
Normal file
66
packages/ui/tests/browser/browser-emulation-native.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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 } }),
|
||||
} })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")!)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue