From 6e2eaa514f62791f402ae4c8829088de0b1b43c7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 12:11:39 +0200 Subject: [PATCH 01/20] refac --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dc50b34d..6a296ac6 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ Close the tab. Come back tomorrow on any device. Everything is where you left it Life is short. Touch grass. Read our [Manifesto](MANIFESTO.md). +> Help us build open, empowering tools that put AI in people's hands. +> [We're hiring](http://careers.openwebui.com/). + ## Docker Run cptr with Docker: From 86ffe37cd857cf2da90a0602bfc93c6a0399fb48 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 12:24:47 +0200 Subject: [PATCH 02/20] refac --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a296ac6..3678f291 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ `cptr` (short for "computer") runs on your machine and serves your whole computer (files, terminal, editor, git) to any browser. It literally is your computer. -Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device. +Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, your computer, any device. ## Install From c152eac6325bdfdb761d6edd1d02fa76fecf6bb4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 18 Jun 2026 10:45:35 +0200 Subject: [PATCH 03/20] refac --- cptr/frontend/src/lib/components/Admin/Web.svelte | 9 +++++++++ cptr/frontend/src/lib/i18n/locales/de.json | 2 ++ cptr/frontend/src/lib/i18n/locales/en.json | 2 ++ cptr/frontend/src/lib/i18n/locales/es.json | 2 ++ cptr/frontend/src/lib/i18n/locales/fr.json | 2 ++ cptr/frontend/src/lib/i18n/locales/ja.json | 2 ++ cptr/frontend/src/lib/i18n/locales/ko.json | 2 ++ cptr/frontend/src/lib/i18n/locales/pt-BR.json | 2 ++ cptr/frontend/src/lib/i18n/locales/ru.json | 2 ++ cptr/frontend/src/lib/i18n/locales/zh-CN.json | 2 ++ cptr/frontend/src/lib/i18n/locales/zh-TW.json | 2 ++ cptr/utils/web/perplexity.py | 9 +++++++-- cptr/utils/web/search.py | 10 +++++++--- 13 files changed, 43 insertions(+), 5 deletions(-) diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte index e053a81f..030bba35 100644 --- a/cptr/frontend/src/lib/components/Admin/Web.svelte +++ b/cptr/frontend/src/lib/components/Admin/Web.svelte @@ -18,6 +18,7 @@ let tavilyKey = $state(''); let braveKey = $state(''); let perplexityKey = $state(''); + let perplexityBaseUrl = $state(''); let ccKey = $state(''); let ccBaseUrl = $state(''); let ccModel = $state(''); @@ -44,6 +45,7 @@ tavilyKey = (config['web.tavily_api_key'] as string) || ''; braveKey = (config['web.brave_api_key'] as string) || ''; perplexityKey = (config['web.perplexity_api_key'] as string) || ''; + perplexityBaseUrl = (config['web.perplexity_base_url'] as string) || ''; ccKey = (config['web.chat_completions_api_key'] as string) || ''; ccBaseUrl = (config['web.chat_completions_base_url'] as string) || ''; ccModel = (config['web.chat_completions_model'] as string) || ''; @@ -74,6 +76,7 @@ 'web.tavily_api_key': tavilyKey, 'web.brave_api_key': braveKey, 'web.perplexity_api_key': perplexityKey, + 'web.perplexity_base_url': perplexityBaseUrl, 'web.chat_completions_api_key': ccKey, 'web.chat_completions_base_url': ccBaseUrl, 'web.chat_completions_model': ccModel, @@ -185,6 +188,12 @@ class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />

{$t('admin.webPerplexityHint')}

+
+ + +

{$t('admin.webPerplexityBaseUrlHint')}

+
{:else if searchProvider === 'chat_completions'}
diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index b8b05e4f..8cb9d66b 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo benötigt keinen API-Schlüssel (Fallback).", "admin.webPerplexityKey": "Perplexity API-Schlüssel", "admin.webPerplexityHint": "API-Schlüssel unter perplexity.ai erhalten", + "admin.webPerplexityBaseUrl": "Basis-URL", + "admin.webPerplexityBaseUrlHint": "Leer lassen für api.perplexity.ai. Ändern für LiteLLM oder andere kompatible Proxys.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "Basis-URL", "admin.webCcKey": "API-Schlüssel", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 9e60fe75..16e9aec8 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -283,6 +283,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo requires no API key (fallback).", "admin.webPerplexityKey": "Perplexity API Key", "admin.webPerplexityHint": "Get your API key at perplexity.ai", + "admin.webPerplexityBaseUrl": "Base URL", + "admin.webPerplexityBaseUrlHint": "Leave blank for api.perplexity.ai. Change for LiteLLM or other compatible proxies.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "Base URL", "admin.webCcKey": "API Key", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index f318917c..5b734f14 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo no requiere clave de API (respaldo).", "admin.webPerplexityKey": "Clave de API de Perplexity", "admin.webPerplexityHint": "Obtén tu clave de API en perplexity.ai", + "admin.webPerplexityBaseUrl": "URL base", + "admin.webPerplexityBaseUrlHint": "Dejar en blanco para api.perplexity.ai. Cambiar para LiteLLM u otros proxies compatibles.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL base", "admin.webCcKey": "Clave de API", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index 86c7edb6..6498cd8c 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo ne nécessite aucune clé API (solution de repli).", "admin.webPerplexityKey": "Clé API Perplexity", "admin.webPerplexityHint": "Obtenez votre clé API sur perplexity.ai", + "admin.webPerplexityBaseUrl": "URL de base", + "admin.webPerplexityBaseUrlHint": "Laisser vide pour api.perplexity.ai. Modifier pour LiteLLM ou d'autres proxies compatibles.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL de base", "admin.webCcKey": "Clé API", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 4e5fc6c2..621170e4 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGoはAPIキー不要です(フォールバック)。", "admin.webPerplexityKey": "Perplexity APIキー", "admin.webPerplexityHint": "perplexity.ai でAPIキーを取得", + "admin.webPerplexityBaseUrl": "ベースURL", + "admin.webPerplexityBaseUrlHint": "api.perplexity.ai を使用する場合は空欄。LiteLLM や他の互換プロキシの場合は変更してください。", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "ベースURL", "admin.webCcKey": "APIキー", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index f159f424..862a7623 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo는 API 키가 필요 없습니다(대체).", "admin.webPerplexityKey": "Perplexity API 키", "admin.webPerplexityHint": "perplexity.ai에서 API 키를 받으세요", + "admin.webPerplexityBaseUrl": "기본 URL", + "admin.webPerplexityBaseUrlHint": "api.perplexity.ai를 사용하려면 비워두세요. LiteLLM 또는 기타 호환 프록시의 경우 변경하세요.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "기본 URL", "admin.webCcKey": "API 키", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index b797ae4e..81bb9f41 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo não exige chave de API (fallback).", "admin.webPerplexityKey": "Chave de API do Perplexity", "admin.webPerplexityHint": "Obtenha sua chave de API em perplexity.ai", + "admin.webPerplexityBaseUrl": "URL base", + "admin.webPerplexityBaseUrlHint": "Deixe em branco para api.perplexity.ai. Altere para LiteLLM ou outros proxies compatíveis.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL base", "admin.webCcKey": "Chave de API", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index e887831a..9ee4ab3d 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo не требует API-ключа (резервный вариант).", "admin.webPerplexityKey": "API-ключ Perplexity", "admin.webPerplexityHint": "Получите API-ключ на perplexity.ai", + "admin.webPerplexityBaseUrl": "Базовый URL", + "admin.webPerplexityBaseUrlHint": "Оставьте пустым для api.perplexity.ai. Измените для LiteLLM или других совместимых прокси.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "Базовый URL", "admin.webCcKey": "API-ключ", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 75a8636b..882e05bd 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo 不需要 API 密钥(备用)。", "admin.webPerplexityKey": "Perplexity API 密钥", "admin.webPerplexityHint": "在 perplexity.ai 获取你的 API 密钥", + "admin.webPerplexityBaseUrl": "基础 URL", + "admin.webPerplexityBaseUrlHint": "留空使用 api.perplexity.ai。如使用 LiteLLM 或其他兼容代理请修改。", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "基础 URL", "admin.webCcKey": "API 密钥", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index cdceede6..f5c31085 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo 不需要 API 金鑰(備用)。", "admin.webPerplexityKey": "Perplexity API 金鑰", "admin.webPerplexityHint": "在 perplexity.ai 取得你的 API 金鑰", + "admin.webPerplexityBaseUrl": "基礎 URL", + "admin.webPerplexityBaseUrlHint": "留空使用 api.perplexity.ai。如使用 LiteLLM 或其他相容代理請修改。", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "基礎 URL", "admin.webCcKey": "API 金鑰", diff --git a/cptr/utils/web/perplexity.py b/cptr/utils/web/perplexity.py index cda0aed2..04411841 100644 --- a/cptr/utils/web/perplexity.py +++ b/cptr/utils/web/perplexity.py @@ -9,11 +9,16 @@ import httpx -async def search(query: str, api_key: str, count: int = 5) -> str: +async def search( + query: str, + api_key: str, + count: int = 5, + base_url: str = "https://api.perplexity.ai", +) -> str: """Search using Perplexity's dedicated search API.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( - "https://api.perplexity.ai/search", + f"{base_url.rstrip('/')}/search", json={"query": query}, headers={ "Authorization": f"Bearer {api_key}", diff --git a/cptr/utils/web/search.py b/cptr/utils/web/search.py index 9ddcfed9..8f31d9ee 100644 --- a/cptr/utils/web/search.py +++ b/cptr/utils/web/search.py @@ -2,7 +2,7 @@ Priority in auto mode: 1. Exa (EXA_API_KEY or web.exa_api_key) -2. Perplexity (PERPLEXITY_API_KEY or web.perplexity_api_key) +2. Perplexity (PERPLEXITY_API_KEY or web.perplexity_api_key, optional PERPLEXITY_BASE_URL or web.perplexity_base_url) 3. Tavily (TAVILY_API_KEY or web.tavily_api_key) 4. Brave (BRAVE_API_KEY or web.brave_api_key) 5. DuckDuckGo (zero-config fallback) @@ -52,6 +52,9 @@ async def web_search_handler(query: str) -> str: exa_key = await _get_key("EXA_API_KEY", "web.exa_api_key") perplexity_key = await _get_key("PERPLEXITY_API_KEY", "web.perplexity_api_key") + perplexity_url = ( + await _get_config("web.perplexity_base_url") + ) or os.environ.get("PERPLEXITY_BASE_URL", "") tavily_key = await _get_key("TAVILY_API_KEY", "web.tavily_api_key") brave_key = await _get_key("BRAVE_API_KEY", "web.brave_api_key") cc_key = await _get_key("CHAT_COMPLETIONS_SEARCH_API_KEY", "web.chat_completions_api_key") @@ -72,7 +75,7 @@ async def web_search_handler(query: str) -> str: elif provider == "perplexity": if not perplexity_key: return "Error: Perplexity API key not configured." - return await perplexity.search(query, perplexity_key) + return await perplexity.search(query, perplexity_key, base_url=perplexity_url) if perplexity_url else await perplexity.search(query, perplexity_key) elif provider == "tavily": if not tavily_key: return "Error: Tavily API key not configured." @@ -98,7 +101,8 @@ async def web_search_handler(query: str) -> str: if exa_key: providers.append(("exa", lambda: exa.search(query, exa_key))) if perplexity_key: - providers.append(("perplexity", lambda: perplexity.search(query, perplexity_key))) + _pplx_kw = {"base_url": perplexity_url} if perplexity_url else {} + providers.append(("perplexity", lambda: perplexity.search(query, perplexity_key, **_pplx_kw))) if tavily_key: providers.append(("tavily", lambda: tavily.search(query, tavily_key))) if brave_key: From 88f743c95ba5cd2e89248b5f3e47076da11fe09c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 18 Jun 2026 11:46:41 +0200 Subject: [PATCH 04/20] refac --- .../lib/components/ConnectionMonitor.svelte | 61 +++++++++++++++ cptr/frontend/src/lib/components/Icon.svelte | 13 ++++ .../lib/components/Settings/General.svelte | 1 - .../src/lib/components/Terminal.svelte | 77 +++++++++++++++++++ .../components/chat/AssistantMessage.svelte | 28 +++++++ .../src/lib/components/chat/ChatInput.svelte | 21 +++++ .../src/lib/components/chat/ChatPanel.svelte | 4 +- cptr/frontend/src/lib/i18n/locales/en.json | 14 +++- cptr/frontend/src/lib/stores.ts | 40 ++++++++++ cptr/frontend/src/lib/stores/audio.ts | 44 ++++++++++- cptr/frontend/src/routes/+layout.svelte | 2 + 11 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 cptr/frontend/src/lib/components/ConnectionMonitor.svelte diff --git a/cptr/frontend/src/lib/components/ConnectionMonitor.svelte b/cptr/frontend/src/lib/components/ConnectionMonitor.svelte new file mode 100644 index 00000000..5deb31d3 --- /dev/null +++ b/cptr/frontend/src/lib/components/ConnectionMonitor.svelte @@ -0,0 +1,61 @@ + diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte index 7825f2d3..45ddb373 100644 --- a/cptr/frontend/src/lib/components/Icon.svelte +++ b/cptr/frontend/src/lib/components/Icon.svelte @@ -380,5 +380,18 @@ {:else if name === 'stop'} + {:else if name === 'share'} + + + + + + {:else if name === 'wifi-off'} + + + + + + {/if} diff --git a/cptr/frontend/src/lib/components/Settings/General.svelte b/cptr/frontend/src/lib/components/Settings/General.svelte index 6634def7..42bbc788 100644 --- a/cptr/frontend/src/lib/components/Settings/General.svelte +++ b/cptr/frontend/src/lib/components/Settings/General.svelte @@ -48,7 +48,6 @@ async function toggleNotifications() { if (!$notificationsEnabled) { - // Enabling: request permission if ('Notification' in window) { const permission = await Notification.requestPermission(); if (permission === 'granted') { diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte index bfe7322c..bb413140 100644 --- a/cptr/frontend/src/lib/components/Terminal.svelte +++ b/cptr/frontend/src/lib/components/Terminal.svelte @@ -51,6 +51,64 @@ let lastSentCols = 0; let lastSentRows = 0; + // ── Wake Lock ───────────────────────────────────────────── + // Keeps the screen alive during terminal sessions so long-running + // commands (npm install, docker build) don't cause screen dimming. + let wakeLock: WakeLockSentinel | null = null; + + async function acquireWakeLock() { + if (wakeLock || destroyed) return; + try { + if ('wakeLock' in navigator) { + wakeLock = await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release', () => { wakeLock = null; }); + } + } catch { + // Wake lock request can fail if document is hidden or permission denied + } + } + + function releaseWakeLock() { + wakeLock?.release().catch(() => {}); + wakeLock = null; + } + + // Re-acquire wake lock when tab becomes visible again + function handleVisibilityChange() { + if (document.visibilityState === 'visible' && !destroyed) { + acquireWakeLock(); + } + } + + // ── Haptic Feedback ────────────────────────────────────── + // Short vibration on command completion: when PTY output pauses + // after sustained activity. Useful for "build finished" when + // phone is in pocket. + let lastOutputTime = 0; + let outputActive = false; + let hapticTimer: ReturnType | null = null; + const HAPTIC_PAUSE_MS = 800; // output must pause this long to trigger + + function trackOutputForHaptics() { + const now = Date.now(); + if (now - lastOutputTime > 2000) { + // Fresh burst of output after a gap - mark as active + outputActive = true; + } + lastOutputTime = now; + + // Reset pause timer + if (hapticTimer) clearTimeout(hapticTimer); + hapticTimer = setTimeout(() => { + if (outputActive && document.hidden) { + // Output was flowing, paused, and user isn't looking - vibrate + if ('vibrate' in navigator) navigator.vibrate(15); + } + outputActive = false; + hapticTimer = null; + }, HAPTIC_PAUSE_MS); + } + function getWsUrl(sid: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${window.location.host}/api/terminal/${sid}/ws`; @@ -132,6 +190,17 @@ onMount(() => { if (!containerEl) return; + // ── Virtual Keyboard API ──────────────────────────────── + // In overlay mode the keyboard covers content instead of resizing + // the viewport, which avoids jarring layout shifts in the terminal. + if ('virtualKeyboard' in navigator) { + (navigator as any).virtualKeyboard.overlaysContent = true; + } + + // Acquire wake lock to keep screen on during terminal session + acquireWakeLock(); + document.addEventListener('visibilitychange', handleVisibilityChange); + term = new Terminal({ cursorBlink: true, cursorStyle: 'bar', @@ -323,6 +392,11 @@ document.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Reset virtual keyboard mode so other views aren't affected + if ('virtualKeyboard' in navigator) { + (navigator as any).virtualKeyboard.overlaysContent = false; + } }; }); @@ -352,6 +426,7 @@ // binaryType='arraybuffer' guarantees ArrayBuffer; write // directly with a Uint8Array view (zero-copy wrapper) term?.write(new Uint8Array(event.data as ArrayBuffer)); + trackOutputForHaptics(); }; ws.onclose = (e) => { @@ -377,7 +452,9 @@ destroyed = true; if (resizeTimeout) clearTimeout(resizeTimeout); if (reconnectTimer) clearTimeout(reconnectTimer); + if (hapticTimer) clearTimeout(hapticTimer); resizeObserver?.disconnect(); + releaseWakeLock(); ws?.close(); term?.dispose(); }); diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 63918c3c..ec9694b7 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -118,6 +118,18 @@ }, 1500); } + function shareContent() { + const text = + (output || []) + .filter((i: any) => i.type === 'message') + .flatMap((i: any) => i.content || []) + .map((c: any) => c.text) + .join('') || content; + if ('share' in navigator) { + navigator.share({ text }).catch(() => {}); + } + } + /** Shorten a file path to just the basename for compact display */ function shortPath(p: string | undefined): string { if (!p) return '?'; @@ -625,6 +637,22 @@ {/if}
{/if} + {#if done && 'share' in navigator} + + {/if} {/if} {/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 7b8a1c94..4e045a8b 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -34,6 +34,24 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import { t } from '$lib/i18n'; + // Keep screen awake during voice mode conversations + let voiceWakeLock: WakeLockSentinel | null = null; + + async function acquireVoiceWakeLock() { + if (voiceWakeLock) return; + try { + if ('wakeLock' in navigator) { + voiceWakeLock = await navigator.wakeLock.request('screen'); + voiceWakeLock.addEventListener('release', () => { voiceWakeLock = null; }); + } + } catch {} + } + + function releaseVoiceWakeLock() { + voiceWakeLock?.release().catch(() => {}); + voiceWakeLock = null; + } + interface Props { inputText: string; selectedModel: string; @@ -517,6 +535,7 @@ onDestroy(() => { stopVoiceRecognition(); if (voiceRestartTimer) clearTimeout(voiceRestartTimer); + releaseVoiceWakeLock(); destroyPopup(); destroySkillPopup(); editor?.destroy(); @@ -810,10 +829,12 @@ voiceModeEnabled.set(next); if (next) { void unlockTtsAudioPlayback(); + void acquireVoiceWakeLock(); voiceWaitingForResponse = false; voiceSawStreaming = false; startVoiceRecognition(); } else { + releaseVoiceWakeLock(); voiceWaitingForResponse = false; voiceSawStreaming = false; stopVoiceRecognition(); diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index cdad2860..91e02072 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -78,6 +78,7 @@ let autoScroll = $state(true); let cancelledMessageId: string | null = null; let loading = $state(!!initialChatId); + let chatTitle = ''; let ttsQueue: string[] = []; let ttsBuffer = ''; let ttsInsideCodeFence = false; @@ -273,6 +274,7 @@ currentMessageId = data.chat.current_message_id; // Update tab label with the real title from the DB if (tabId && data.chat.title) { + chatTitle = data.chat.title; updateTab(tabId, id, data.chat.title); } } finally { @@ -1225,7 +1227,7 @@ if (generation !== ttsGeneration) break; if (ttsObjectUrl) URL.revokeObjectURL(ttsObjectUrl); ttsObjectUrl = URL.createObjectURL(blob); - ttsAudio = setTtsAudioPlaybackSource(ttsObjectUrl) ?? new Audio(ttsObjectUrl); + ttsAudio = setTtsAudioPlaybackSource(ttsObjectUrl, chatTitle) ?? new Audio(ttsObjectUrl); await new Promise((resolve, reject) => { const audio = ttsAudio!; audio.onended = () => resolve(); diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 16e9aec8..475e48ad 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -801,5 +801,17 @@ "onboarding.suggestion3": "What files are here?", "onboarding.suggestion4": "Help me write tests", "onboarding.connectAiNudge": "Connect an AI provider to start chatting.", - "onboarding.goToConnections": "Settings → Connections" + "onboarding.goToConnections": "Settings → Connections", + + "pwa.reconnecting": "Reconnecting", + "pwa.reconnectingTo": "Reconnecting to {{hostname}}", + "chat.share": "Share", + + "general.storage": "Storage", + "general.cacheUsage": "Cache usage", + "general.persistentStorage": "Persistent storage", + "general.persistentYes": "Active", + "general.persistentNo": "Not granted", + "general.clearCache": "Clear cache", + "general.cacheCleared": "Cache cleared" } diff --git a/cptr/frontend/src/lib/stores.ts b/cptr/frontend/src/lib/stores.ts index 787be490..250b2d9b 100644 --- a/cptr/frontend/src/lib/stores.ts +++ b/cptr/frontend/src/lib/stores.ts @@ -493,6 +493,46 @@ if (typeof window !== 'undefined') { }); } +// ── Cross-tab state sync (BroadcastChannel) ───────────────────── +// Syncs theme and locale changes between browser tabs / PWA windows +// so the user doesn't see divergent state. + +if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel('cptr-sync'); + + let _syncingFromBroadcast = false; + + channel.onmessage = (e: MessageEvent) => { + const { type, value } = e.data || {}; + _syncingFromBroadcast = true; + try { + if (type === 'theme' && value) { + theme.set(value); + } else if (type === 'locale' && value) { + changeLocale(value); + } + } finally { + _syncingFromBroadcast = false; + } + }; + + theme.subscribe((t) => { + if (!_syncingFromBroadcast) { + channel.postMessage({ type: 'theme', value: t }); + } + }); + + // Locale changes are broadcast from changeLocale() calls; + // subscribe to i18next language changes. + if (i18next) { + i18next.on('languageChanged', (lng: string) => { + if (!_syncingFromBroadcast) { + channel.postMessage({ type: 'locale', value: lng }); + } + }); + } +} + // ── Workspace actions ─────────────────────────────────────────── /** diff --git a/cptr/frontend/src/lib/stores/audio.ts b/cptr/frontend/src/lib/stores/audio.ts index 6840acd2..bbf7267e 100644 --- a/cptr/frontend/src/lib/stores/audio.ts +++ b/cptr/frontend/src/lib/stores/audio.ts @@ -60,7 +60,7 @@ export function getTtsAudioElement(): HTMLAudioElement | null { return ttsAudioElement; } -export function setTtsAudioPlaybackSource(src: string): HTMLAudioElement | null { +export function setTtsAudioPlaybackSource(src: string, title?: string): HTMLAudioElement | null { const audio = getTtsAudioElement(); if (!audio) return null; ttsAudioUseToken += 1; @@ -69,9 +69,51 @@ export function setTtsAudioPlaybackSource(src: string): HTMLAudioElement | null audio.muted = false; audio.playbackRate = currentTtsPlaybackSpeed; audio.src = src; + + // Shows playback controls on lock screen (Android) and + // Control Center (iOS) while TTS is playing. + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: title || 'cptr', + artist: 'cptr', + artwork: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' } + ] + }); + navigator.mediaSession.setActionHandler('play', () => { + audio.play().catch(() => {}); + }); + navigator.mediaSession.setActionHandler('pause', () => { + audio.pause(); + }); + navigator.mediaSession.setActionHandler('stop', () => { + audio.pause(); + audio.currentTime = 0; + clearMediaSession(); + }); + + // Clear media session when playback ends naturally + const currentToken = ttsAudioUseToken; + audio.addEventListener('ended', function onEnd() { + audio.removeEventListener('ended', onEnd); + if (ttsAudioUseToken === currentToken) clearMediaSession(); + }); + } + return audio; } +/** Clear media session metadata and handlers. */ +export function clearMediaSession() { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = null; + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + navigator.mediaSession.setActionHandler('stop', null); + } +} + export async function unlockTtsAudioPlayback() { if (typeof window === 'undefined') return; diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index 72dd8d74..e10c0d7e 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import AuthScreen from '$lib/components/AuthScreen.svelte'; import ChangelogModal from '$lib/components/ChangelogModal.svelte'; import UpdateToast from '$lib/components/UpdateToast.svelte'; + import ConnectionMonitor from '$lib/components/ConnectionMonitor.svelte'; import { Toaster } from 'svelte-sonner'; import { activeTab, @@ -371,6 +372,7 @@ {/if} + Date: Thu, 18 Jun 2026 11:52:11 +0200 Subject: [PATCH 05/20] refac --- .../lib/components/ConnectionMonitor.svelte | 49 +++++++++---------- cptr/frontend/src/lib/stores/socket.svelte.ts | 3 +- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/cptr/frontend/src/lib/components/ConnectionMonitor.svelte b/cptr/frontend/src/lib/components/ConnectionMonitor.svelte index 5deb31d3..49464f8d 100644 --- a/cptr/frontend/src/lib/components/ConnectionMonitor.svelte +++ b/cptr/frontend/src/lib/components/ConnectionMonitor.svelte @@ -2,16 +2,16 @@ import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; import { t } from '$lib/i18n'; + import { socketStore } from '$lib/stores/socket.svelte'; let isOnline = $state(true); let toastId: string | number | undefined; - let healthTimer: ReturnType | null = null; + let hasSeenSocketConnection = $state(false); function showOffline() { if (!isOnline) return; isOnline = false; toastId = toast.error($t('pwa.reconnecting'), { duration: Infinity }); - startPolling(); } function showOnline() { @@ -19,42 +19,39 @@ isOnline = true; if (toastId) toast.dismiss(toastId); toastId = undefined; - stopPolling(); } - function checkHealth() { - fetch('/api/health', { method: 'HEAD', cache: 'no-store' }) - .then((res) => { - if (res.ok) showOnline(); - }) - .catch(() => {}); + function handleOnline() { + if (socketStore.connected) { + showOnline(); + } } - function startPolling() { - stopPolling(); - healthTimer = setInterval(checkHealth, 5000); - } + $effect(() => { + const socket = socketStore.getSocket(); + const connected = socketStore.connected; - function stopPolling() { - if (healthTimer) { - clearInterval(healthTimer); - healthTimer = null; + if (!socket) { + hasSeenSocketConnection = false; + showOnline(); + return; } - } + + if (connected) { + hasSeenSocketConnection = true; + showOnline(); + } else if (hasSeenSocketConnection) { + showOffline(); + } + }); onMount(() => { - // Only react to browser offline/online events. - // Don't probe on mount; if we loaded, we're online. window.addEventListener('offline', showOffline); - window.addEventListener('online', () => { - // Browser says online, verify with a real request - checkHealth(); - }); + window.addEventListener('online', handleOnline); return () => { window.removeEventListener('offline', showOffline); - window.removeEventListener('online', checkHealth); - stopPolling(); + window.removeEventListener('online', handleOnline); if (toastId) toast.dismiss(toastId); }; }); diff --git a/cptr/frontend/src/lib/stores/socket.svelte.ts b/cptr/frontend/src/lib/stores/socket.svelte.ts index 5e1e7fe4..9d8b5302 100644 --- a/cptr/frontend/src/lib/stores/socket.svelte.ts +++ b/cptr/frontend/src/lib/stores/socket.svelte.ts @@ -28,8 +28,9 @@ function connect() { } function disconnect() { - socket?.disconnect(); + const currentSocket = socket; socket = null; + currentSocket?.disconnect(); _connected = false; } From 637bfa74ce933189b7384b973bb8523dfb4bcfa7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 18 Jun 2026 12:06:03 +0200 Subject: [PATCH 06/20] refac --- cptr/app.py | 7 ++++ cptr/frontend/src/app.html | 1 + cptr/frontend/static/manifest.json | 61 +++++++++++++++++------------- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/cptr/app.py b/cptr/app.py index d894cfad..8a212955 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -329,11 +329,14 @@ async def pwa_manifest(): name = f"cptr @ {hostname}" if hostname else "cptr" return { + "id": "/", "name": name, "short_name": "cptr", "description": f"Your computer, from anywhere. v{version}", "start_url": "/", + "scope": "/", "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], "orientation": "any", "background_color": "#000000", "theme_color": "#000000", @@ -348,6 +351,10 @@ async def pwa_manifest(): }, ], "categories": ["developer", "productivity", "utilities"], + "launch_handler": {"client_mode": ["navigate-existing", "auto"]}, + "handle_links": "preferred", + "prefer_related_applications": False, + "related_applications": [], } diff --git a/cptr/frontend/src/app.html b/cptr/frontend/src/app.html index c16e7799..fd19c1fb 100644 --- a/cptr/frontend/src/app.html +++ b/cptr/frontend/src/app.html @@ -16,6 +16,7 @@ + %sveltekit.head% diff --git a/cptr/frontend/static/manifest.json b/cptr/frontend/static/manifest.json index 89841c98..3f21857b 100644 --- a/cptr/frontend/static/manifest.json +++ b/cptr/frontend/static/manifest.json @@ -1,29 +1,36 @@ { - "name": "cptr", - "short_name": "cptr", - "description": "Your computer, from anywhere.", - "start_url": "/", - "display": "standalone", - "orientation": "any", - "background_color": "#000000", - "theme_color": "#000000", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "categories": ["developer", "productivity", "utilities"] + "id": "/", + "name": "cptr", + "short_name": "cptr", + "description": "Your computer, from anywhere.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "orientation": "any", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "categories": ["developer", "productivity", "utilities"], + "launch_handler": {"client_mode": ["navigate-existing", "auto"]}, + "handle_links": "preferred", + "prefer_related_applications": false, + "related_applications": [] } From 0659b42a556f16272b315c9d4e0bcca95dff70df Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 18 Jun 2026 12:09:04 +0200 Subject: [PATCH 07/20] refac --- cptr/app.py | 1 + cptr/frontend/static/manifest.json | 1 + 2 files changed, 2 insertions(+) diff --git a/cptr/app.py b/cptr/app.py index 8a212955..3a0ce426 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -353,6 +353,7 @@ async def pwa_manifest(): "categories": ["developer", "productivity", "utilities"], "launch_handler": {"client_mode": ["navigate-existing", "auto"]}, "handle_links": "preferred", + "note_taking": {"new_note_url": "/?action=newFile"}, "prefer_related_applications": False, "related_applications": [], } diff --git a/cptr/frontend/static/manifest.json b/cptr/frontend/static/manifest.json index 3f21857b..c0462332 100644 --- a/cptr/frontend/static/manifest.json +++ b/cptr/frontend/static/manifest.json @@ -31,6 +31,7 @@ "categories": ["developer", "productivity", "utilities"], "launch_handler": {"client_mode": ["navigate-existing", "auto"]}, "handle_links": "preferred", + "note_taking": {"new_note_url": "/?action=newFile"}, "prefer_related_applications": false, "related_applications": [] } From a8b73fe63f7b43b556b42e9c03ce0c172b848edc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 18 Jun 2026 12:15:32 +0200 Subject: [PATCH 08/20] refac --- cptr/frontend/src/lib/components/Bar.svelte | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cptr/frontend/src/lib/components/Bar.svelte b/cptr/frontend/src/lib/components/Bar.svelte index 4602e640..930e3fe1 100644 --- a/cptr/frontend/src/lib/components/Bar.svelte +++ b/cptr/frontend/src/lib/components/Bar.svelte @@ -64,7 +64,7 @@