Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
16 changes: 15 additions & 1 deletion cptr/frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="text-scale" content="scale" />
<link rel="icon" type="image/png" href="/favicon.png" />
Expand Down
39 changes: 35 additions & 4 deletions cptr/frontend/src/lib/components/Terminal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -374,7 +383,10 @@
});
</script>

<div bind:this={containerEl} class="h-full w-full pt-1 pl-2 overflow-hidden"></div>
<div class="flex flex-col h-full w-full">
<div bind:this={containerEl} class="flex-1 min-h-0 pt-1 pl-2 overflow-hidden"></div>
<!-- On mobile, xterm's textarea gets moved here as a flex sibling -->
</div>

<style>
@reference "../../app.css";
Expand All @@ -388,4 +400,23 @@
scrollbar-color: rgba(75, 85, 99, 0.4) transparent;
overscroll-behavior: contain;
}
/* When moved to flex sibling on mobile, override xterm's offscreen
positioning. Make it a real in-flow element like chat's textarea.
Use opacity:1 with transparent colors — iOS ignores opacity:0
elements for viewport adjustment. */
/* :global(.xterm-helper-textarea) {
position: relative !important;
top: auto !important;
left: auto !important;
width: 100% !important;
height: 1px !important;
opacity: 1 !important;
color: transparent !important;
background: transparent !important;
caret-color: transparent !important;
border: none !important;
outline: none !important;
resize: none !important;
flex-shrink: 0;
} */
</style>
8 changes: 3 additions & 5 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ────
Expand Down
9 changes: 8 additions & 1 deletion cptr/frontend/src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export interface UserPreferences {
workspaceOrder?: string[]; // ordered paths for sidebar drag-reorder
keybindings?: Record<string, string>; // 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';
Expand Down Expand Up @@ -138,6 +139,7 @@ export const gitReviewOpen = writable(false);
export const isGitRepo = writable(false);
export type StreamingBehavior = 'queue' | 'interrupt';
export const streamingBehavior = writable<StreamingBehavior>('queue');
export const selectedModelId = writable<string>('');

/** Saved workspace path order for sidebar drag-reorder. */
export const workspaceOrder = writable<string[]>([]);
Expand Down Expand Up @@ -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<string, unknown>).catch(() => {});
}, 300);
Expand Down Expand Up @@ -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();
});
Expand All @@ -327,6 +333,7 @@ export async function loadPreferences(): Promise<void> {
if (Array.isArray(prefs.workspaceOrder)) workspaceOrder.set(prefs.workspaceOrder as string[]);
if (prefs.keybindings) loadKeybindings(prefs.keybindings as Record<string, string>);
if (prefs.version) lastSeenVersion.set(prefs.version as string);
if (prefs.selectedModelId) selectedModelId.set(prefs.selectedModelId as string);
} catch {
// First run, no preferences yet
}
Expand Down
36 changes: 17 additions & 19 deletions cptr/frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof setTimeout>;
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);
});

Expand Down Expand Up @@ -249,12 +248,11 @@
/>
{:else if $stateLoaded}
<div
class="flex overflow-hidden font-sans antialiased text-gray-900 bg-white dark:text-gray-100 dark:bg-black"
style="height: {viewportHeight};"
class="h-screen max-h-[100dvh] flex overflow-hidden font-sans antialiased text-gray-900 bg-white dark:text-gray-100 dark:bg-black"
>
<Sidebar />

<div class="flex flex-col flex-1 min-w-0">
<div id="main-col" class="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
{#if !$currentWorkspace}
<Bar />
{/if}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading