diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f1bcae..36971d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,28 @@ 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.9.12] - 2026-07-21 + +### Added + +- 📱 **Unsaved file edits now get a proper warning.** Closing an edited file tab now shows Computer's own confirmation dialog, so mobile users and desktop users can choose to keep editing or close without saving. +- 🎛️ **Browser streaming now has an encoder setting.** Admins can leave streaming on Auto, force hardware encoding, or force software encoding from Admin > Web > Streaming quality > Advanced. + +### Changed + +- 🌍 **More of Computer is translated.** Admin screens, chat controls, diff settings, file editor labels, skill controls, and planning prompts now use the same language as the rest of the app across all supported languages. +- 📜 **The license is clearer for hosted use.** The old multi-tenant hosting restriction was removed while the attribution requirements remain in place. + +### Fixed + +- 🌐 **Browser tabs work on servers without video hardware.** Streaming now defaults to Auto, so headless servers, virtual machines, and other GPU-light hosts can fall back to software encoding instead of failing to open the browser stream. +- 🤖 **Claude Code appears when it comes from the Claude desktop app.** If Claude Code was installed through Claude desktop on macOS or Windows, Computer can now find it automatically and show it as an available coding agent. + ## [0.9.11] - 2026-07-19 ### Added -- 🤖 **Gemini is now its own coding agent.** Choose Gemini as an agent and Computer will start the Gemini CLI with its Google sign-in flow, keep the chat resumable, and handle image attachments like the other coding agents. +- 🤖 **Gemini is now its own coding agent.** Choose Gemini as an agent and Computer will start it with its Google sign-in flow, keep the chat resumable, and handle image attachments like the other coding agents. ### Fixed diff --git a/LICENSE b/LICENSE index bdc1bfd9..2cfb6da4 100644 --- a/LICENSE +++ b/LICENSE @@ -18,11 +18,6 @@ Attribution elements are any elements identifying the origin of the software, such as logos, icons, visual marks, the product name, copyright notices, attribution statements, and about screens or version information. -Multi-Tenant Use - -You may not host, deploy, manage, or otherwise facilitate multiple instances -of the software or its derivative works as a service. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE diff --git a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte index 52fe31a2..1796c4df 100644 --- a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte @@ -78,7 +78,7 @@ try { verifyResult = await verifyBotToken(platform, token.trim()); } catch (e: any) { - verifyResult = { ok: false, error: e.message || 'Verification failed' }; + verifyResult = { ok: false, error: e.message || $t('messaging.verificationFailed') }; } finally { verifying = false; } @@ -149,7 +149,7 @@
- Provider Type + {$t('connections.providerType')} {/if} diff --git a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte index e208414b..d124e598 100644 --- a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte @@ -156,18 +156,15 @@ -
@@ -587,6 +601,30 @@ class="h-7 w-24 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-none dark:border-white/8 dark:bg-white/6 dark:text-gray-300" />
+
+
+ {$t('admin.browserEncoderHardwareAcceleration')} +

+ {$t('admin.browserEncoderHardwareAccelerationHint')} +

+
+ +
{:else} diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte index ea599393..4c4c3d0a 100644 --- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte +++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte @@ -1,5 +1,6 @@ + + + +{#if $confirmDialog} + +
settleConfirm(false)} + onkeydown={() => {}} + > + + +
+{/if} + + diff --git a/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte b/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte index a67a1908..e115d657 100644 --- a/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte +++ b/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte @@ -1,5 +1,6 @@ {#if loading} -
Loading...
+
{$t('common.loading')}
{:else if error}
{error}
{:else if kind === 'image'} diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 884cb828..e7458b6d 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -1177,9 +1177,9 @@ {#if slashCommandIds.includes('compact')} @@ -1359,9 +1359,9 @@ {#if slashCommandIds.includes('skills:create')} {/if} {#if slashSkillSuggestions.length > 0} -
Skills
+
+ {$t('chat.skills')} +
{#each slashSkillSuggestions as skill (skill.id)}
- Skills + {$t('chat.skills')}
@@ -39,7 +40,7 @@ {item.name}
- {item.managed ? 'managed' : 'read-only'} + {item.managed ? $t('chat.skillManaged') : $t('chat.skillReadOnly')}
@@ -49,7 +50,9 @@ {/each}
{:else} -
No skills found
+
+ {$t('chat.noSkillsFound')} +
{/if} diff --git a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte index 155b215d..14a96f2f 100644 --- a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte +++ b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte @@ -235,8 +235,8 @@
{question.question}
- {answer || 'No answer recorded'}{answer === question.options?.[0]?.label - ? ' (Recommended)' + {answer || $t('chat.noAnswerRecorded')}{answer === question.options?.[0]?.label + ? ` (${$t('chat.recommended')})` : ''}
diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index 82757f21..fc01c05a 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -134,7 +134,7 @@ aria-expanded={timerExpanded} onclick={() => (timerExpanded = !timerExpanded)} > - Timer + {$t('chat.timer')} {content} { const ws = get(currentWorkspace); - if (!ws) return; + if (!ws) return false; // Find the group containing this tab const gid = groupId ?? ws.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id ?? ws.activeGroupId; const group = ws.groups.find((g) => g.id === gid); - if (!group) return; + if (!group) return false; const tab = group.tabs.find((t) => t.id === tabId); - if (!tab || tab.permanent) return; + if (!tab || tab.permanent) return false; + + if (tab.unsaved && !options.skipUnsavedPrompt) { + const confirmed = await requestConfirm({ + title: i18next.t('editor.closeUnsavedTitle', { + defaultValue: 'Unsaved changes' + }), + message: i18next.t('editor.closeUnsavedConfirm', { + name: tab.label, + defaultValue: 'Close "{{name}}" without saving changes?' + }), + cancelLabel: i18next.t('common.cancel', { defaultValue: 'Cancel' }), + confirmLabel: i18next.t('editor.closeWithoutSaving', { + defaultValue: 'Close without saving' + }) + }); + if (!confirmed) return false; + } if (tab.type === 'terminal' && tab.sessionId) { deleteSession(tab.sessionId); @@ -1296,6 +1318,7 @@ export function closeTab(tabId: string, groupId?: string): void { activeGroupId: activeGroupStillExists ? ws.activeGroupId : newGroups[0].id }; }); + return true; } export function setActiveTab(tabId: string, groupId?: string): void { diff --git a/cptr/frontend/src/lib/stores/confirm.ts b/cptr/frontend/src/lib/stores/confirm.ts new file mode 100644 index 00000000..fed77509 --- /dev/null +++ b/cptr/frontend/src/lib/stores/confirm.ts @@ -0,0 +1,42 @@ +import { get, writable } from 'svelte/store'; + +export interface ConfirmOptions { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; +} + +export interface ConfirmRequest extends Required { + id: number; + resolve: (confirmed: boolean) => void; +} + +export const confirmDialog = writable(null); + +let nextId = 0; + +export function requestConfirm(options: ConfirmOptions): Promise { + if (typeof window === 'undefined') return Promise.resolve(false); + + const existing = get(confirmDialog); + if (existing) existing.resolve(false); + + return new Promise((resolve) => { + confirmDialog.set({ + id: ++nextId, + title: options.title, + message: options.message, + confirmLabel: options.confirmLabel ?? 'Confirm', + cancelLabel: options.cancelLabel ?? 'Cancel', + resolve + }); + }); +} + +export function settleConfirm(confirmed: boolean): void { + const request = get(confirmDialog); + if (!request) return; + confirmDialog.set(null); + request.resolve(confirmed); +} diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index 4700a977..de498be9 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -12,6 +12,7 @@ import SettingsModal from '$lib/components/SettingsModal.svelte'; import AuthScreen from '$lib/components/AuthScreen.svelte'; import ChangelogModal from '$lib/components/ChangelogModal.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import UpdateToast from '$lib/components/UpdateToast.svelte'; import { Toaster, toast } from 'svelte-sonner'; import { @@ -507,3 +508,4 @@ style: 'font-size: 0.75rem; font-family: var(--font-sans); border-radius: 0.5rem;' }} /> + diff --git a/cptr/frontend/static/browser-encoder.js b/cptr/frontend/static/browser-encoder.js index accb4421..7a81b370 100644 --- a/cptr/frontend/static/browser-encoder.js +++ b/cptr/frontend/static/browser-encoder.js @@ -2,6 +2,11 @@ const params = new URLSearchParams(location.hash.slice(1)); const windowCapture = params.get('window') === '1'; const audioEnabled = params.get('audio') === '1'; + const accelerationOptions = new Set(['no-preference', 'prefer-hardware', 'prefer-software']); + const requestedAcceleration = params.get('hardwareAcceleration') || 'no-preference'; + const hardwareAcceleration = accelerationOptions.has(requestedAcceleration) + ? requestedAcceleration + : 'no-preference'; let socket; let encoder; let audioEncoder; @@ -65,7 +70,7 @@ } async function configure(width, height) { - const key = `${width}x${height}:${quality.bitrate}:${quality.frame_rate}`; + const key = `${width}x${height}:${quality.bitrate}:${quality.frame_rate}:${hardwareAcceleration}`; if (configured === key) return; const config = { codec: 'avc1.42E028', @@ -73,12 +78,12 @@ height, bitrate: quality.bitrate, framerate: quality.frame_rate, - hardwareAcceleration: 'prefer-hardware', + hardwareAcceleration, latencyMode: 'realtime', avc: { format: 'annexb' } }; const support = await VideoEncoder.isConfigSupported(config); - if (!support.supported) throw new Error(`H.264 WebCodecs encoding is unavailable at ${width}×${height}; lower Admin > Web > Streaming quality > Advanced > Max height`); + if (!support.supported) throw new Error(`H.264 WebCodecs encoding is unavailable at ${width}×${height} with ${hardwareAcceleration}; lower Admin > Web > Streaming quality > Advanced > Max height or change encoder acceleration`); if (encoder) { try { await encoder.flush(); } catch {} encoder.close(); diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index 3ef05614..98dbb567 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -9,8 +9,8 @@ import re import shutil import socket -from dataclasses import dataclass, asdict from contextlib import suppress +from dataclasses import dataclass, asdict from typing import Any import httpx @@ -59,6 +59,39 @@ def _resolve_command(command: str) -> str | None: return shutil.which(command) +def _find_claude_desktop_command() -> str | None: + if os.name == "nt": + appdata = os.environ.get("APPDATA") + root = os.path.join(appdata, "Claude", "claude-code") if appdata else None + relative_paths = (("claude.exe",),) + else: + root = os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "Claude", + "claude-code", + ) + relative_paths = ( + ("claude.app", "Contents", "MacOS", "claude"), + ("claude",), + ) + + candidates: list[tuple[tuple[int, int, int], str]] = [] + if root is not None and os.path.isdir(root): + for name in os.listdir(root): + version_dir = os.path.join(root, name) + version = _parse_version_tuple(name) + if version is None or not os.path.isdir(version_dir): + continue + for relative_path in relative_paths: + candidate = os.path.join(version_dir, *relative_path) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + candidates.append((version, candidate)) + break + return max(candidates)[1] if candidates else None + + async def _run_probe( argv: list[str], timeout: float = 3.0, @@ -82,7 +115,10 @@ async def _run_probe( async def detect_profile(profile: dict[str, Any]) -> AgentDetection: - command = _resolve_command(str(profile.get("command") or "")) + raw_command = str(profile.get("command") or "").strip() + command = _resolve_command(raw_command) + if command is None and profile.get("agent") == "claude_code" and raw_command == "claude": + command = _find_claude_desktop_command() if command is None: return AgentDetection("not_found", None, None, "Command not found") @@ -549,6 +585,12 @@ async def get_agent_status(app_state=None, refresh: bool = False) -> dict[str, A and (mode != "auto" or detected.status == "ready") ) effective_profile = dict(profile) + if ( + detected.command + and profile.get("agent") == "claude_code" + and _resolve_command(str(profile.get("command") or "")) is None + ): + effective_profile["command"] = detected.command effective_profile["models"] = models if models and effective_profile.get("default_model") not in models: effective_profile["default_model"] = models[0] diff --git a/cptr/utils/browser/viewer.py b/cptr/utils/browser/viewer.py index 83a7de33..46a9b0ff 100644 --- a/cptr/utils/browser/viewer.py +++ b/cptr/utils/browser/viewer.py @@ -32,6 +32,7 @@ EventHandler = Callable[[dict[str, Any]], Awaitable[None]] QUALITY_PRESETS = ("low", "balanced", "crisp") +ENCODER_HARDWARE_ACCELERATION = ("no-preference", "prefer-hardware", "prefer-software") DEFAULT_QUALITY_PROFILES = { "low": {"bitrate": 3_000_000, "frame_rate": 15}, "balanced": {"bitrate": 6_000_000, "frame_rate": 24}, @@ -616,8 +617,17 @@ async def start(self, session: Any, origin: str, *, cdp_url: str = "") -> Chrome base = local_origin(origin) ws_scheme = "wss" if base.startswith("https:") else "ws" ws_url = f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/{session.session_id}/encoder" + hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration") + if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION: + hardware_acceleration = "no-preference" fragment = urlencode( - {"session": session.session_id, "token": token, "ws": ws_url, "audio": "1"} + { + "session": session.session_id, + "token": token, + "ws": ws_url, + "audio": "1", + "hardwareAcceleration": hardware_acceleration, + } ) controller_url = f"{base}/browser-encoder.html#{fragment}" controller_id, controller_cdp = await host.create_target(controller_url) @@ -684,7 +694,17 @@ async def connect_personal(self, owner: str, origin: str, cdp_url: str) -> Perso base = local_origin(origin) ws_scheme = "wss" if base.startswith("https:") else "ws" ws_url = f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/personal/encoder" - fragment = urlencode({"token": token, "ws": ws_url, "window": "1"}) + hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration") + if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION: + hardware_acceleration = "no-preference" + fragment = urlencode( + { + "token": token, + "ws": ws_url, + "window": "1", + "hardwareAcceleration": hardware_acceleration, + } + ) controller_id, controller_cdp = await host.create_target( f"{base}/browser-encoder.html#{fragment}", new_window=True ) @@ -1041,12 +1061,16 @@ async def _restart_encoder(self, viewer: ChromeViewer) -> None: f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/" f"{viewer.session.session_id}/encoder" ) + hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration") + if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION: + hardware_acceleration = "no-preference" fragment = urlencode( { "session": viewer.session.session_id, "token": viewer.encoder_token, "ws": ws_url, "audio": "1", + "hardwareAcceleration": hardware_acceleration, } ) viewer.controller_id, viewer.controller_cdp = await viewer.host.create_target( diff --git a/pyproject.toml b/pyproject.toml index 1db23441..487e0747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.11" +version = "0.9.12" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 6cdcc6e9..030bf8c9 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.11" +version = "0.9.12" source = { editable = "." } dependencies = [ { name = "aiosqlite" },