diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f1d21..56e616a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.4.9] - 2026-06-15 + +### Fixed + +- 📱 **Mobile keyboard no longer covers the chat.** On iOS and mobile browsers, opening the on-screen keyboard could push content behind it or clip the bottom of the screen. The layout now properly adjusts to keep everything visible above the keyboard. +- 🗑️ **Discarding staged files works correctly.** Previously, discarding a file that was already staged (added to a commit) could leave it in a broken state. Staged changes are now properly unstaged and cleaned up in one step. +- 🔄 **Git file list no longer flickers when staging.** Toggling a file between staged and unstaged could briefly cause a visual glitch where the list jumped. The file list now updates smoothly. + +### Improved + +- 📋 **Dropdown menus stay on screen.** Dropdown menus (like the model picker) now correctly reposition themselves to stay within the visible area, especially when the keyboard is open or on smaller screens. +- 📖 **README updated with badges and clearer tunnel docs.** Added project badges (stars, language, Discord) to the top of the README, and clarified the remote access / tunnel instructions. + ## [0.4.8] - 2026-06-15 ### Fixed diff --git a/README.md b/README.md index c66d989..640e480 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # cptr +![GitHub stars](https://img.shields.io/github/stars/open-webui/computer?style=social) +![GitHub forks](https://img.shields.io/github/forks/open-webui/computer?style=social) +![GitHub watchers](https://img.shields.io/github/watchers/open-webui/computer?style=social) +![GitHub repo size](https://img.shields.io/github/repo-size/open-webui/computer) +![GitHub language count](https://img.shields.io/github/languages/count/open-webui/computer) +![GitHub top language](https://img.shields.io/github/languages/top/open-webui/computer) +![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/computer?color=red) +[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) +[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) + ![Cptr Demo](./demo.png) The computer used to be a room. Then a desk. Then a bag. Now it's a URL. @@ -35,12 +45,14 @@ cptr run --host 0.0.0.0 Open `http://:8000` on your phone. -Not on the same network? Use a tunnel: +Not on the same network? Use a tunnel to reach your machine remotely: - **[Tailscale](https://tailscale.com)** creates a private mesh network between your devices. Recommended. - **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** gives you a permanent URL through Cloudflare's edge. - **[ngrok](https://ngrok.com)** gives you a public URL in one command. +Most tunnels forward to `localhost`, so the default `cptr run` works. If your tunnel connects to a specific interface, bind accordingly with `--host`. + Or skip networking entirely and connect a [messaging bot](#messaging-bots) instead. ## What you get diff --git a/cptr/frontend/src/lib/components/DropdownMenu.svelte b/cptr/frontend/src/lib/components/DropdownMenu.svelte index 37fc2ce..6740d98 100644 --- a/cptr/frontend/src/lib/components/DropdownMenu.svelte +++ b/cptr/frontend/src/lib/components/DropdownMenu.svelte @@ -25,6 +25,10 @@ matchWidth?: boolean; /** Prefer opening above the anchor instead of below. */ preferAbove?: boolean; + /** Keep the menu above the anchor and shrink it to fit the visible viewport. */ + forceAbove?: boolean; + /** Position above the trigger in normal layout instead of fixed to the viewport. */ + inlineAbove?: boolean; /** Max height for the items list (CSS value). */ maxHeight?: string; /** Optional header snippet rendered above items (e.g. search input). */ @@ -43,6 +47,8 @@ onclose, matchWidth = false, preferAbove = false, + forceAbove = false, + inlineAbove = false, maxHeight, header, empty, @@ -51,13 +57,106 @@ }: Props = $props(); let menuEl: HTMLDivElement | undefined = $state(); - let pos = $state<{ x: number; top?: number; bottom?: number }>({ x: -9999 }); + let pos = $state<{ x: number; top: number }>({ x: -9999, top: -9999 }); let anchorWidth = $state(0); + let menuMaxHeight = $state(); let ready = $state(false); + let frame: number | undefined; + let anchorFrame: number | undefined; + let settleTimers: number[] = []; + let lastViewportState = ''; + let lastAnchorState = ''; + + function portal(node: HTMLElement, enabled = true) { + const parent = node.parentNode; + const sibling = node.nextSibling; + + function move() { + if (enabled && node.parentNode !== document.body) { + document.body.appendChild(node); + } else if (!enabled && parent && node.parentNode === document.body) { + parent.insertBefore(node, sibling); + } + } + + move(); + + return { + update(nextEnabled: boolean) { + enabled = nextEnabled; + move(); + }, + destroy() { + node.remove(); + } + }; + } + + function visualViewportRect() { + const vv = window.visualViewport; + return { + left: vv?.offsetLeft ?? 0, + top: vv?.offsetTop ?? 0, + width: vv?.width ?? window.innerWidth, + height: vv?.height ?? window.innerHeight + }; + } + + function viewportState() { + const viewport = visualViewportRect(); + return [ + viewport.left, + viewport.top, + viewport.width, + viewport.height, + window.innerWidth, + window.innerHeight + ].join(':'); + } + + function anchorState() { + if (!(anchor instanceof HTMLElement)) { + return `${anchor.x}:${anchor.y}:${viewportState()}`; + } + + const rect = anchor.getBoundingClientRect(); + return [ + rect.left, + rect.top, + rect.right, + rect.bottom, + rect.width, + rect.height, + viewportState() + ] + .map((value) => (typeof value === 'number' ? value.toFixed(2) : value)) + .join(':'); + } + + function measureMenu() { + if (!menuEl) return { width: 0, height: 0 }; + + const previousMaxHeight = menuEl.style.maxHeight; + menuEl.style.maxHeight = ''; + const size = { + width: menuEl.offsetWidth, + height: menuEl.offsetHeight + }; + menuEl.style.maxHeight = previousMaxHeight; + return size; + } function updatePosition() { if (!menuEl) return; + if (inlineAbove) { + if (matchWidth && anchor instanceof HTMLElement) { + anchorWidth = anchor.getBoundingClientRect().width; + } + ready = true; + return; + } + let ax: number; let anchorTop: number; let anchorBottom: number; @@ -74,10 +173,10 @@ anchorBottom = anchor.y; } - const mw = menuEl.offsetWidth; - const mh = menuEl.offsetHeight; - const vw = window.innerWidth; - const vh = window.innerHeight; + const { width: mw, height: mh } = measureMenu(); + const viewport = visualViewportRect(); + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; const pad = 8; const gap = 4; @@ -86,46 +185,135 @@ const rect = anchor.getBoundingClientRect(); ax = rect.right - mw; } - if (ax + mw > vw - pad) ax = vw - mw - pad; - if (ax < pad) ax = pad; + if (ax + mw > viewportRight - pad) ax = viewportRight - mw - pad; + if (ax < viewport.left + pad) ax = viewport.left + pad; // Vertical: collision detection - const spaceAbove = anchorTop - gap - pad; - const spaceBelow = vh - anchorBottom - gap - pad; + const spaceAbove = anchorTop - viewport.top - gap - pad; + const spaceBelow = viewportBottom - anchorBottom - gap - pad; - if (preferAbove) { - if (mh <= spaceAbove) { - pos = { x: ax, bottom: vh - anchorTop + gap }; - } else { - pos = { x: ax, top: anchorBottom + gap }; - } + let nextTop: number; + let availableHeight: number; + + if (forceAbove || (preferAbove && (mh <= spaceAbove || spaceAbove >= spaceBelow))) { + availableHeight = spaceAbove; + const visibleMenuHeight = Math.min(mh, Math.max(0, availableHeight)); + nextTop = anchorTop - gap - visibleMenuHeight; } else { if (mh <= spaceBelow) { - pos = { x: ax, top: anchorBottom + gap }; + availableHeight = spaceBelow; + nextTop = anchorBottom + gap; } else { - pos = { x: ax, bottom: vh - anchorTop + gap }; + availableHeight = spaceAbove; + const visibleMenuHeight = Math.min(mh, Math.max(0, availableHeight)); + nextTop = anchorTop - gap - visibleMenuHeight; } } + menuMaxHeight = + availableHeight >= 0 && (forceAbove || mh > availableHeight || menuMaxHeight != null) + ? Math.max(0, availableHeight) + : undefined; + const maxTop = viewportBottom - pad - (menuMaxHeight ?? mh); + nextTop = Math.min(Math.max(nextTop, viewport.top + pad), Math.max(viewport.top + pad, maxTop)); + pos = { x: ax, top: nextTop }; ready = true; } + function scheduleUpdate() { + if (frame != null) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = undefined; + updatePosition(); + }); + } + + function scheduleSettledUpdates() { + for (const timer of settleTimers) window.clearTimeout(timer); + settleTimers = []; + scheduleUpdate(); + for (const delay of [50, 150, 300]) { + settleTimers.push(window.setTimeout(scheduleUpdate, delay)); + } + } + + function handleViewportChange() { + const nextViewportState = viewportState(); + if (nextViewportState === lastViewportState) return; + lastViewportState = nextViewportState; + scheduleSettledUpdates(); + } + + function handleFocusIn(event: FocusEvent) { + if (event.target instanceof Node && menuEl?.contains(event.target)) { + scheduleSettledUpdates(); + } + } + + function trackAnchor() { + const nextAnchorState = anchorState(); + if (nextAnchorState !== lastAnchorState) { + lastAnchorState = nextAnchorState; + updatePosition(); + } + anchorFrame = requestAnimationFrame(trackAnchor); + } + onMount(() => { - requestAnimationFrame(updatePosition); + let dvhProbe: HTMLDivElement | undefined; + let dvhObserver: ResizeObserver | undefined; + + lastViewportState = viewportState(); + lastAnchorState = anchorState(); + scheduleUpdate(); + anchorFrame = requestAnimationFrame(trackAnchor); // Follow anchor on scroll/resize - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', scheduleUpdate, true); + window.addEventListener('resize', scheduleSettledUpdates); + window.visualViewport?.addEventListener('resize', scheduleSettledUpdates); + window.visualViewport?.addEventListener('scroll', scheduleUpdate); + document.addEventListener('focusin', handleFocusIn); + + if ('ResizeObserver' in window) { + dvhProbe = document.createElement('div'); + dvhProbe.style.position = 'fixed'; + dvhProbe.style.left = '-1px'; + dvhProbe.style.top = '0'; + dvhProbe.style.width = '1px'; + dvhProbe.style.height = '100dvh'; + dvhProbe.style.pointerEvents = 'none'; + dvhProbe.style.visibility = 'hidden'; + document.body.appendChild(dvhProbe); + + dvhObserver = new ResizeObserver(handleViewportChange); + dvhObserver.observe(dvhProbe); + } + return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); + if (frame != null) cancelAnimationFrame(frame); + if (anchorFrame != null) cancelAnimationFrame(anchorFrame); + for (const timer of settleTimers) window.clearTimeout(timer); + dvhObserver?.disconnect(); + dvhProbe?.remove(); + window.removeEventListener('scroll', scheduleUpdate, true); + window.removeEventListener('resize', scheduleSettledUpdates); + window.visualViewport?.removeEventListener('resize', scheduleSettledUpdates); + window.visualViewport?.removeEventListener('scroll', scheduleUpdate); + document.removeEventListener('focusin', handleFocusIn); }; }); + + $effect(() => { + maxHeight; + if (menuEl) scheduleSettledUpdates(); + });
{ e.preventDefault(); @@ -135,22 +323,29 @@
e.stopPropagation()} onmousedown={(e) => e.stopPropagation()} > {#if header} - {@render header()} -
+
+ {@render header()} +
+
{/if} -
+
{#if items.length === 0 && empty} {@render empty()} {:else} diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index cf31bea..9b1dd2b 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -685,7 +685,7 @@
- {#each gitStatus?.files ?? [] as file (file.path)} + {#each gitStatus?.files ?? [] as file (`${file.path}:${file.staged}`)} {@const fp = fPath(file.path)} {@const sc = statusChar(file.status)} + {#if $chatModels.length > 0} + + + + {/if} + -{#if open && btnEl && $chatModels.length > 0} - (open = false)} - preferAbove={preferAbove} - maxHeight="15rem" - className="w-48" - align={align} - > - {#snippet header()} -
- - - - { - if (e.key === 'Escape') open = false; - }} - /> -
- {/snippet} - {#snippet empty()} -
{$t('modelSelector.noMatches')}
- {/snippet} -
-{/if} + {#if open && btnEl && $chatModels.length > 0} + (open = false)} + preferAbove={preferAbove} + forceAbove={preferAbove} + maxHeight={selectorMaxHeight} + className="w-48" + align={align} + > + {#snippet header()} +
+ + + + { + if (e.key === 'Escape') open = false; + }} + /> +
+ {/snippet} + {#snippet empty()} +
{$t('modelSelector.noMatches')}
+ {/snippet} +
+ {/if} + diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index c424f64..b7d92b3 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -75,29 +75,32 @@ }, 30 * 60 * 1000 ); - // 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. + // iOS Safari keeps 100vh/100dvh at the layout viewport height when + // the keyboard opens. visualViewport tells us how much of the bottom + // of that layout viewport is covered, so reserve that space inside the + // main column instead of moving or clipping the whole shell. const vv = window.visualViewport; - if (vv) { - 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` : ''; - }; - // iOS may fire 'scroll' instead of 'resize' when keyboard opens - vv.addEventListener('resize', syncHeight); - vv.addEventListener('scroll', syncHeight); - return () => { - clearInterval(healthCheck); - vv.removeEventListener('resize', syncHeight); - vv.removeEventListener('scroll', syncHeight); - }; - } - return () => clearInterval(healthCheck); + const syncKeyboardInset = () => { + const visualBottom = vv ? vv.offsetTop + vv.height : window.innerHeight; + const keyboardInset = Math.max(0, window.innerHeight - visualBottom); + document.documentElement.style.setProperty( + '--keyboard-inset-bottom', + keyboardInset > 100 ? `${keyboardInset}px` : '0px' + ); + }; + + syncKeyboardInset(); + window.addEventListener('resize', syncKeyboardInset); + // iOS may fire 'scroll' instead of 'resize' when keyboard opens. + vv?.addEventListener('resize', syncKeyboardInset); + vv?.addEventListener('scroll', syncKeyboardInset); + return () => { + clearInterval(healthCheck); + document.documentElement.style.removeProperty('--keyboard-inset-bottom'); + window.removeEventListener('resize', syncKeyboardInset); + vv?.removeEventListener('resize', syncKeyboardInset); + vv?.removeEventListener('scroll', syncKeyboardInset); + }; }); let startupToken = $state(''); @@ -325,7 +328,11 @@ > -
+
{#if !$currentWorkspace && $page.url.pathname === '/'} {/if} diff --git a/cptr/utils/git.py b/cptr/utils/git.py index 013c463..d9ebede 100644 --- a/cptr/utils/git.py +++ b/cptr/utils/git.py @@ -232,12 +232,17 @@ async def unstage(root: str, files: list[str]) -> None: async def discard(root: str, files: list[str]) -> None: - """Discard changes in files. Tracked files are restored via git checkout; - untracked files are deleted from disk.""" + """Fully discard all changes for files — both staged and unstaged. + + Tracked modified/deleted files are unstaged then restored via checkout. + Newly added (staged) files are unstaged then deleted from disk. + Untracked files are deleted from disk. + """ if not files: return - # Determine which files are untracked + requested = set(files) + _, st_out, _ = await _run( "status", "--porcelain=v2", @@ -245,17 +250,48 @@ async def discard(root: str, files: list[str]) -> None: cwd=root, check=False, ) - untracked = set() - for line in st_out.splitlines(): - if line.startswith("? "): - untracked.add(line[2:]) - tracked = [f for f in files if f not in untracked] - to_delete = [f for f in files if f in untracked] - - if tracked: - await _run("checkout", "--", *tracked, cwd=root) + to_unstage: list[str] = [] # staged changes that need unstaging first + to_checkout: list[str] = [] # working-tree changes to restore from HEAD + to_delete: list[str] = [] # untracked / newly-added files to remove + for line in st_out.splitlines(): + if line.startswith("? "): + path = line[2:] + if path in requested: + to_delete.append(path) + elif line.startswith("1 ") or line.startswith("2 "): + parts = line.split(" ", 8) + xy = parts[1] + path = parts[-1] + if line.startswith("2 "): + path = path.split("\t")[0] + if path not in requested: + continue + staged_code = xy[0] + unstaged_code = xy[1] + if staged_code == "A": + # Newly added file: unstage → becomes untracked → delete + to_unstage.append(path) + to_delete.append(path) + elif staged_code != ".": + # Staged modification/deletion: unstage then checkout + to_unstage.append(path) + to_checkout.append(path) + if unstaged_code != "." and staged_code != "A": + # Working-tree change: checkout (deduplicated) + if path not in to_checkout: + to_checkout.append(path) + + # 1. Unstage any staged changes (reverts index to HEAD) + if to_unstage: + await _run("restore", "--staged", "--", *to_unstage, cwd=root) + + # 2. Restore working-tree files from HEAD + if to_checkout: + await _run("checkout", "--", *to_checkout, cwd=root) + + # 3. Remove untracked / newly-added files for f in to_delete: full = os.path.join(root, f) if os.path.isfile(full): diff --git a/pyproject.toml b/pyproject.toml index 28b756d..dc7bcab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.4.8" +version = "0.4.9" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"