diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10f3d1be..0bedd62c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.1.2] - 2026-06-06
+
+### Fixed
+
+- 📱 **Improved mobile keyboard handling.** The terminal and chat now resize correctly when the on-screen keyboard opens on iOS and Android.
+- 📱 **Fixed page bouncing on mobile.** Eliminated unwanted page scrolling and bounce effects on touch devices.
+
+### Changed
+
+- 🔄 **Model selection syncs across devices.** Your selected chat model now persists across browsers and devices instead of being saved locally.
+
## [0.1.1] - 2026-06-06
### Fixed
diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json
index 13dedd29..0dcf7428 100644
--- a/cptr/frontend/package.json
+++ b/cptr/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.1.0",
+ "version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css
index 807c3bd4..31bdf3e7 100644
--- a/cptr/frontend/src/app.css
+++ b/cptr/frontend/src/app.css
@@ -31,10 +31,24 @@
border-color: var(--color-gray-200, currentColor);
}
+ html,
+ body {
+ height: 100dvh;
+ margin: 0;
+ overflow: hidden;
+ overscroll-behavior: none;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ html::-webkit-scrollbar,
+ body::-webkit-scrollbar {
+ display: none;
+ }
+
html {
color-scheme: dark light;
touch-action: manipulation;
- overscroll-behavior: none;
}
button {
diff --git a/cptr/frontend/src/app.html b/cptr/frontend/src/app.html
index ac0a430a..06594b23 100644
--- a/cptr/frontend/src/app.html
+++ b/cptr/frontend/src/app.html
@@ -4,7 +4,7 @@
diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte
index 286ea694..bfe7322c 100644
--- a/cptr/frontend/src/lib/components/Terminal.svelte
+++ b/cptr/frontend/src/lib/components/Terminal.svelte
@@ -62,9 +62,6 @@
if (rect.width === 0 || rect.height === 0) return;
try {
fitAddon.fit();
- // Do NOT scroll after fit. Rich CLI apps (htop, vim, Claude Code)
- // manage their own cursor/viewport after SIGWINCH. Forcing scroll
- // interferes with their rendering.
} catch {
// FitAddon can throw if terminal not properly attached
}
@@ -192,6 +189,18 @@
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerEl);
+ // // iOS: Move xterm's textarea from inside .xterm-helpers (position:absolute,
+ // // offscreen) to a flex sibling BELOW the terminal.
+ // if ('ontouchstart' in window && term.textarea) {
+ // containerEl.after(term.textarea);
+ // term.focus = () => term.textarea?.focus();
+ //
+ // // xterm's mousedown calls ev.preventDefault() which blocks iOS
+ // // viewport adjustment. Focus on touchstart (fires before mousedown).
+ // containerEl.addEventListener('touchstart', () => {
+ // term.textarea?.focus();
+ // }, { passive: true });
+ // }
// GPU-accelerated rendering, 2-5x faster than canvas 2D.
// Falls back to canvas if WebGL is unavailable.
@@ -374,7 +383,10 @@
});
-
+
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
index 47490acd..c157c791 100644
--- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
+++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
@@ -24,7 +24,7 @@
import { socketStore } from '$lib/stores/socket.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { get } from 'svelte/store';
- import { currentWorkspace, toolApprovalMode, streamingBehavior } from '$lib/stores';
+ import { currentWorkspace, toolApprovalMode, streamingBehavior, selectedModelId } from '$lib/stores';
import ChatInput from './ChatInput.svelte';
import UserMessage from './UserMessage.svelte';
@@ -355,11 +355,9 @@
if (chatId) loadChat(chatId);
}
- const MODEL_STORAGE_KEY = 'cptr:chat:lastModel';
-
onMount(() => {
const models = get(chatModels);
- const saved = localStorage.getItem(MODEL_STORAGE_KEY);
+ const saved = get(selectedModelId);
const dm = get(defaultModel);
if (saved && models.some((m) => m.id === saved)) selectedModel = saved;
else if (dm) selectedModel = dm;
@@ -397,7 +395,7 @@
// ── Persist model selection ─────────────────────────────────
$effect(() => {
- if (selectedModel) localStorage.setItem(MODEL_STORAGE_KEY, selectedModel);
+ if (selectedModel) selectedModelId.set(selectedModel);
});
// ── Sync streaming state to shared store for tab icon ────
diff --git a/cptr/frontend/src/lib/stores.ts b/cptr/frontend/src/lib/stores.ts
index 6a02af6e..1bae2c9f 100644
--- a/cptr/frontend/src/lib/stores.ts
+++ b/cptr/frontend/src/lib/stores.ts
@@ -74,6 +74,7 @@ export interface UserPreferences {
workspaceOrder?: string[]; // ordered paths for sidebar drag-reorder
keybindings?: Record; // user-customised keyboard shortcuts
version?: string; // last seen app version for changelog
+ selectedModelId?: string; // last selected chat model, synced across browsers
}
export type Theme = 'dark' | 'light' | 'system';
@@ -138,6 +139,7 @@ export const gitReviewOpen = writable(false);
export const isGitRepo = writable(false);
export type StreamingBehavior = 'queue' | 'interrupt';
export const streamingBehavior = writable('queue');
+export const selectedModelId = writable('');
/** Saved workspace path order for sidebar drag-reorder. */
export const workspaceOrder = writable([]);
@@ -269,7 +271,8 @@ function persistPreferences(): void {
locale: i18next.language,
workspaceOrder: get(workspaceOrder),
keybindings: get(keybindings),
- version: get(lastSeenVersion)
+ version: get(lastSeenVersion),
+ selectedModelId: get(selectedModelId) || undefined
};
savePreferences(prefs as unknown as Record).catch(() => {});
}, 300);
@@ -303,6 +306,9 @@ function subscribeForPersistence() {
lastSeenVersion.subscribe(() => {
if (get(stateLoaded)) persistPreferences();
});
+ selectedModelId.subscribe(() => {
+ if (get(stateLoaded)) persistPreferences();
+ });
i18next.on('languageChanged', () => {
if (get(stateLoaded)) persistPreferences();
});
@@ -327,6 +333,7 @@ export async function loadPreferences(): Promise {
if (Array.isArray(prefs.workspaceOrder)) workspaceOrder.set(prefs.workspaceOrder as string[]);
if (prefs.keybindings) loadKeybindings(prefs.keybindings as Record);
if (prefs.version) lastSeenVersion.set(prefs.version as string);
+ if (prefs.selectedModelId) selectedModelId.set(prefs.selectedModelId as string);
} catch {
// First run, no preferences yet
}
diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte
index f28d6cb2..3248c0d0 100644
--- a/cptr/frontend/src/routes/+layout.svelte
+++ b/cptr/frontend/src/routes/+layout.svelte
@@ -38,7 +38,6 @@
let { children } = $props();
let showQuickOpen = $state(false);
let showSettings = $state(false);
- let viewportHeight = $state('100dvh');
// Auth state
type AuthState = 'checking' | 'needs_setup' | 'needs_login' | 'authenticated';
@@ -65,28 +64,28 @@
},
30 * 60 * 1000
);
-
- // iOS/Android: visualViewport gives accurate height when keyboard is open.
- // Debounce to avoid firing dozens of resizes during the keyboard animation
- // (~300ms). We only commit the final height after things settle, which
- // prevents TUI apps from receiving multiple SIGWINCH and re-rendering.
+ // iOS: Termius-style keyboard handling. visualViewport.height
+ // gives us the area above the keyboard. Set max-height on the
+ // main content column so the terminal shrinks to fit.
+ // The layout container's overflow:hidden clips the gap.
const vv = window.visualViewport;
- let vpTimer: ReturnType;
if (vv) {
- const update = () => {
- clearTimeout(vpTimer);
- vpTimer = setTimeout(() => {
- viewportHeight = `${vv.height}px`;
- }, 350);
+ const syncHeight = () => {
+ const col = document.getElementById('main-col');
+ if (!col) return;
+ const kbHeight = window.innerHeight - vv.height;
+ console.log('[viewport]', { innerHeight: window.innerHeight, vvHeight: vv.height, kbHeight });
+ col.style.maxHeight = kbHeight > 100 ? `${vv.height}px` : '';
};
- vv.addEventListener('resize', update);
+ // iOS may fire 'scroll' instead of 'resize' when keyboard opens
+ vv.addEventListener('resize', syncHeight);
+ vv.addEventListener('scroll', syncHeight);
return () => {
- clearTimeout(vpTimer);
clearInterval(healthCheck);
- vv.removeEventListener('resize', update);
+ vv.removeEventListener('resize', syncHeight);
+ vv.removeEventListener('scroll', syncHeight);
};
}
-
return () => clearInterval(healthCheck);
});
@@ -249,12 +248,11 @@
/>
{:else if $stateLoaded}
-
+
{#if !$currentWorkspace}
{/if}
diff --git a/pyproject.toml b/pyproject.toml
index fd5ca13f..747aa8ea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.1.1"
+version = "0.1.2"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"