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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -35,12 +45,14 @@ cptr run --host 0.0.0.0

Open `http://<your-computer-ip>: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
Expand Down
255 changes: 225 additions & 30 deletions cptr/frontend/src/lib/components/DropdownMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -43,6 +47,8 @@
onclose,
matchWidth = false,
preferAbove = false,
forceAbove = false,
inlineAbove = false,
maxHeight,
header,
empty,
Expand All @@ -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<number | undefined>();
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;
Expand All @@ -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;

Expand All @@ -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();
});
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100]"
use:portal={!inlineAbove}
class="fixed inset-0 z-[1000]"
onclick={onclose}
oncontextmenu={(e) => {
e.preventDefault();
Expand All @@ -135,22 +323,29 @@

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
use:portal={!inlineAbove}
bind:this={menuEl}
class="fixed z-[101] min-w-36 rounded-xl bg-white dark:bg-[#1a1a1a] border border-gray-150 dark:border-white/6 shadow-xl p-0.5 {className}"
style="left: {pos.x}px; {pos.bottom != null
? `bottom: ${pos.bottom}px`
: `top: ${pos.top ?? -9999}px`}; {anchorWidth ? `width: ${anchorWidth}px;` : ''} opacity: {ready
class="{inlineAbove
? `absolute bottom-full mb-1 ${align === 'end' ? 'right-0' : 'left-0'}`
: 'fixed'} z-[1001] min-w-36 rounded-xl bg-white dark:bg-[#1a1a1a] border border-gray-150 dark:border-white/6 shadow-xl p-0.5 flex flex-col overflow-hidden {className}"
style="{inlineAbove
? ''
: `left: ${pos.x}px; top: ${pos.top}px; ${menuMaxHeight
? `max-height: ${menuMaxHeight}px;`
: ''}`} {anchorWidth ? `width: ${anchorWidth}px;` : ''} opacity: {ready
? 1
: 0}; pointer-events: {ready ? 'auto' : 'none'};"
onclick={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
>
{#if header}
{@render header()}
<div class="h-px bg-gray-100/50 dark:bg-white/3 mx-1 my-0.5"></div>
<div class="flex-none">
{@render header()}
<div class="h-px bg-gray-100/50 dark:bg-white/3 mx-1 my-0.5"></div>
</div>
{/if}

<div style={maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''}>
<div class="flex-1 min-h-0 overflow-y-auto" style={maxHeight ? `max-height: ${maxHeight};` : ''}>
{#if items.length === 0 && empty}
{@render empty()}
{:else}
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/components/GitBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@

<!-- File list -->
<div class="flex-1 overflow-y-auto">
{#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)}
<button
Expand Down
Loading
Loading