diff --git a/CHANGELOG.md b/CHANGELOG.md index 8607129c..59ffa461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.0] - 2026-07-11 + +### Added + +- ๐ŸŒ **A browser in your workspace.** Open websites and local app previews in their own tabs, with back and forward navigation, mobile views, and display-quality choices. +- ๐Ÿค– **More coding agents.** Add Gemini or Pi alongside the coding tools you already use. +- ๐Ÿ”Ž **Search inside files.** Find file names and matching text, then jump straight to the right line. +- ๐Ÿ’ฌ **A more useful home screen.** Start a chat, terminal, or browser before opening a workspace, and arrange them side by side. + +### Changed + +- ๐Ÿ—‚๏ธ **Easier chat organization.** Rename chats, copy their saved location, and load more of a workspace's chats without leaving the sidebar. +- โœจ **More control over the view.** Choose a wider layout and decide whether tool details stay expanded. + ## [0.8.8] - 2026-07-09 ### Added diff --git a/MANIFESTO.md b/MANIFESTO.md index bbf84c94..54ce201e 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -1,6 +1,6 @@ # Manifesto -> **TL;DR** We pushed code and went to dinner. Prod broke. Phone in pocket, laptop at home. The fix was on our computer, not in some fresh cloud box. So we built `cptr`: your actual machine, in any browser. Files, terminal, editor, git, running sessions, all of it. Phone, tablet, laptop, whatever. It literally is your computer. Now go outside. +> **TL;DR** We pushed code and went to dinner. Prod broke. Phone in pocket, laptop at home. The fix was on our computer, not in some fresh cloud box. So we built Open WebUI Computer: your actual machine, in any browser. Files, terminal, editor, git, running sessions, all of it. Phone, tablet, laptop, whatever. It literally is your computer. Now go outside. Your computer is where your work lives. diff --git a/README.md b/README.md index d4ea90a6..9badcb65 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Open WebUI Computer (cptr) +# Open WebUI Computer ![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) @@ -12,12 +12,12 @@ ![Open WebUI Computer Demo](./demo.png) -See more at [cptr.sh](https://cptr.sh/) - Open WebUI Computer (`cptr`) runs on your machine and serves your whole computer to any browser: files, terminal, editor, git, running sessions, AI agents, and tools. 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. Connect your own AI via API key, plug in a coding agent you already subscribe to, or work directly in the terminal. One tool, full workstation, any device. +> Start here: [Open WebUI Computer docs](https://docs.openwebui.com/ecosystem/computer/) + ## Install ```bash @@ -101,11 +101,11 @@ Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endp Use the subscriptions you already pay for as native backends on your own machine. No separate API key needed. -**Codex** ยท **Claude Code** ยท **Cursor** ยท **Grok** ยท **OpenCode** ยท **Cline** +**Codex** ยท **Claude Code** ยท **Cursor** ยท **Grok** ยท **OpenCode** ยท **Cline** ยท **Gemini** ยท **Pi** Add an agent profile from Settings, pick your models, and it shows up in the model selector like any other provider. Conversations run inside your workspace with full tool access and resume where you left off. -Prefer to run agents yourself? Any terminal agent (Gemini CLI, Kilo Code, Pi, and others) works in the terminal tab the way it always has. +Prefer to run agents yourself? Any terminal agent (Kilo Code, Pi, and others) works in the terminal tab the way it always has. ## Messaging bots diff --git a/cptr/app.py b/cptr/app.py index e9412dc1..de66f80e 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -11,6 +11,7 @@ auth_router, automations_router, bridge_router, + browser_router, webhook_router, chat_router, events_router, @@ -20,7 +21,6 @@ images_router, memory_router, notifications_router, - proxy_router, search_router, skills_router, state_router, @@ -84,11 +84,15 @@ async def shutdown(): await cancel_all_async_subagents(reason="shutdown") except Exception: pass - # Clean up browser sessions and launched Chrome + # Clean up browser sessions and launched Chrome used by agent tools. try: from cptr.utils.browser.session import session_manager from cptr.utils.browser.launcher import shutdown_browser + from cptr.utils.browser.proxy import manager as browser_proxy_manager + from cptr.utils.browser.viewer import manager as chrome_viewer_manager + await chrome_viewer_manager.close_all() + await browser_proxy_manager.close_all() await session_manager.close_all() await shutdown_browser() except Exception: @@ -153,14 +157,6 @@ async def auth_middleware(request: Request, call_next): ) -# Proxy fallback middleware: intercepts sub-resource requests for proxied -# dev servers (JS modules, CSS, etc.) based on the Referer header chain. -# Added after auth middleware so it wraps outermost (runs before auth), -# since proxied sub-resources don't need cptr auth. -from cptr.utils.proxy_middleware import ProxyFallbackMiddleware - -app.add_middleware(ProxyFallbackMiddleware) - # CORS middleware: uses CPTR_CORS_ALLOWED_ORIGINS env var (default "*"). from fastapi.middleware.cors import CORSMiddleware from cptr.env import CORS_ALLOWED_ORIGINS @@ -260,6 +256,7 @@ async def get_config(): app.include_router(auth_router) app.include_router(automations_router) app.include_router(bridge_router) +app.include_router(browser_router) app.include_router(webhook_router) app.include_router(chat_router) app.include_router(events_router) @@ -269,7 +266,6 @@ async def get_config(): app.include_router(images_router) app.include_router(memory_router) app.include_router(notifications_router) -app.include_router(proxy_router) app.include_router(search_router) app.include_router(skills_router) app.include_router(state_router) diff --git a/cptr/cli.py b/cptr/cli.py index 4820d6d3..df5be2d7 100644 --- a/cptr/cli.py +++ b/cptr/cli.py @@ -20,17 +20,20 @@ def cli(): @click.option("--headless", is_flag=True, default=False, help="Don't open browser.") def run(host: str, port: int, reload: bool, headless: bool): """Start the cptr server.""" - import os, secrets + import os + import secrets display_host = "localhost" if host == "0.0.0.0" else host token = secrets.token_hex(32) os.environ["CPTR_STARTUP_TOKEN"] = token + os.environ["CPTR_PORT"] = str(port) url = f"http://{display_host}:{port}/?token={token}" print(f"\n โžœ {url}\n") if not headless: - import threading, webbrowser + import threading + import webbrowser threading.Timer(1.5, lambda: webbrowser.open(url)).start() uvicorn.run( diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index e8c9e6e1..bf7d6c46 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -29,8 +29,8 @@ --app-fg: #525252; --app-fg-muted: color-mix(in oklab, var(--app-fg) 62%, var(--app-bg)); --app-fg-subtle: color-mix(in oklab, var(--app-fg) 48%, var(--app-bg)); - --app-border: color-mix(in oklab, var(--app-fg) 10%, transparent); - --app-divider: color-mix(in oklab, var(--app-fg) 8%, transparent); + --app-border: color-mix(in oklab, var(--app-fg) 7%, transparent); + --app-divider: color-mix(in oklab, var(--app-fg) 5%, transparent); --app-hover: color-mix(in oklab, var(--app-fg) 6%, transparent); --app-active: color-mix(in oklab, var(--app-fg) 7%, transparent); --app-ui-font: var(--font-sans); diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index e54d32a4..a8140ffe 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -60,7 +60,15 @@ export const updateConfig = (config: Record) => // โ”€โ”€ Agents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode' | 'cline'; +export type AgentType = + | 'codex' + | 'claude_code' + | 'cursor' + | 'grok' + | 'opencode' + | 'cline' + | 'gemini' + | 'pi'; export type AgentMode = 'auto' | 'enabled' | 'disabled'; export type AgentStatus = 'ready' | 'not_found' | 'missing_dependency' | 'auth_unknown' | 'error'; diff --git a/cptr/frontend/src/lib/apis/browser.ts b/cptr/frontend/src/lib/apis/browser.ts new file mode 100644 index 00000000..7eb0d3cb --- /dev/null +++ b/cptr/frontend/src/lib/apis/browser.ts @@ -0,0 +1,91 @@ +import { fetchHandler, fetchJSON } from '$lib/apis'; + +export interface BrowserSession { + session_id: string; + url: string; + title: string; + mode: 'proxy' | 'chrome'; + status: string; +} + +export interface BrowserAvailability { + proxy: { available: true }; + chrome: { + available: boolean; + reason: string | null; + }; +} + +export interface PersonalChromeStatus { + status: 'disconnected' | 'connecting' | 'playing' | 'lost' | 'unavailable'; + browser: string; + session_count: number; +} + +let availabilityPromise: Promise | undefined; +export const getBrowserAvailability = () => + (availabilityPromise ??= fetchJSON('/api/browser/availability')); + +export const createBrowserSession = (url?: string) => + fetchJSON('/api/browser/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url }) + }); + +export const listBrowserSessions = async () => { + const data = await fetchJSON<{ session_ids: string[] }>('/api/browser/sessions'); + return data.session_ids; +}; + +export const deleteBrowserSession = (sessionId: string) => + fetchHandler(`/api/browser/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + +export const getBrowserSession = (sessionId: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`); + +export const updateBrowserSession = (sessionId: string, url: string, title: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url, title }) + }); + +export const setBrowserMode = (sessionId: string, mode: 'proxy' | 'chrome', url: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode, url }) + }); + +export const browserFrameUrl = (sessionId: string, rawUrl: string) => { + const normalized = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`; + const url = new URL(normalized); + return `/api/browser/frame/${sessionId}/${url.protocol.slice(0, -1)}/${encodeURIComponent(url.host)}${url.pathname}${url.search}${url.hash}`; +}; + +export const browserBlankUrl = (sessionId: string) => `/api/browser/sessions/${sessionId}/blank`; + +export const clearManagedChromeProfile = () => + fetchJSON<{ status: string; closed_session_ids: string[] }>('/api/browser/profile', { + method: 'DELETE' + }); + +export const testBrowserCdp = (url: string) => + fetchJSON<{ browser: string }>('/api/browser/cdp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url }) + }); + +export const getPersonalChrome = () => fetchJSON('/api/browser/personal'); + +export const connectPersonalChrome = (url: string) => + fetchJSON('/api/browser/personal', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url }) + }); + +export const disconnectPersonalChrome = () => + fetchJSON('/api/browser/personal', { method: 'DELETE' }); diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 6c0d6a8e..6914f378 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -79,14 +79,14 @@ export interface CompactChatResult { // โ”€โ”€ Queries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export const getChats = ( - workspace: string, + workspace?: string, limit = 50, offset = 0, sortBy: 'title' | 'updated_at' = 'updated_at', sortDir: 'asc' | 'desc' = 'desc' ) => fetchJSON<{ chats: ChatInfo[]; total: number; has_more: boolean }>( - `/api/chats?workspace=${encodeURIComponent(workspace)}&limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_dir=${sortDir}` + `/api/chats?${workspace ? `workspace=${encodeURIComponent(workspace)}&` : ''}limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_dir=${sortDir}` ); export const getChat = (chatId: string, modelId?: string) => { @@ -97,6 +97,13 @@ export const getChat = (chatId: string, modelId?: string) => { export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); +export const updateChatTitle = (chatId: string, title: string) => + fetchJSON<{ ok: boolean; title: string }>(`/api/chats/${chatId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }); + export const forkChat = (chatId: string, messageId?: string | null) => fetchJSON<{ ok: boolean; chat_id: string }>( `/api/chats/${chatId}/fork`, @@ -108,7 +115,7 @@ export const forkChat = (chatId: string, messageId?: string | null) => export const sendMessage = ( content: string, modelId: string, - workspace: string, + workspace?: string, chatId?: string, parentId?: string | null, params: ChatSendParams = {}, @@ -120,7 +127,7 @@ export const sendMessage = ( jsonBody({ content, model_id: modelId, - workspace, + ...(workspace ? { workspace } : {}), chat_id: chatId, parent_id: parentId ?? null, regeneration_prompt: regenerationPrompt, diff --git a/cptr/frontend/src/lib/apis/files.ts b/cptr/frontend/src/lib/apis/files.ts index 525bf0d6..d29e01a8 100644 --- a/cptr/frontend/src/lib/apis/files.ts +++ b/cptr/frontend/src/lib/apis/files.ts @@ -40,3 +40,26 @@ export const searchFiles = (query: string, path: string) => fetchJSON( `/api/workspace/files/search?query=${encodeURIComponent(query)}&path=${encodeURIComponent(path)}` ); + +export type ContentMatch = { line: number; column: number; text: string }; +export type FileMatch = { + path: string; + relative_path: string; + name: string; + type: 'file' | 'directory'; + name_match: boolean; + content_matches: ContentMatch[]; +}; +export type FileMatches = { results: FileMatch[]; next_offset: number | null }; + +export const getFileMatches = ( + query: string, + path: string, + showHidden: boolean, + offset = 0, + signal?: AbortSignal +) => + fetchJSON( + `/api/workspace/files/matches?query=${encodeURIComponent(query)}&path=${encodeURIComponent(path)}&show_hidden=${showHidden}&offset=${offset}`, + { signal } + ); diff --git a/cptr/frontend/src/lib/apis/search.ts b/cptr/frontend/src/lib/apis/search.ts index bd96a4cb..4c5dc722 100644 --- a/cptr/frontend/src/lib/apis/search.ts +++ b/cptr/frontend/src/lib/apis/search.ts @@ -45,5 +45,8 @@ export const unifiedSearch = ( return fetchJSON(`/api/search?${params}`); }; -export const getRecentChats = (limit = 9) => - fetchJSON<{ chats: ChatSearchResult[] }>(`/api/search/recent?limit=${limit}`); +export const getRecentChats = (limit = 9, workspace?: string) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (workspace) params.set('workspace', workspace); + return fetchJSON<{ chats: ChatSearchResult[] }>(`/api/search/recent?${params}`); +}; diff --git a/cptr/frontend/src/lib/apis/skills.ts b/cptr/frontend/src/lib/apis/skills.ts index d0403258..b02ebdbc 100644 --- a/cptr/frontend/src/lib/apis/skills.ts +++ b/cptr/frontend/src/lib/apis/skills.ts @@ -21,5 +21,7 @@ export interface SkillInfo { last_updated_at?: string | null; } -export const getSkills = (workspace: string) => - fetchJSON(`/api/skills?workspace=${encodeURIComponent(workspace)}`); +export const getSkills = (workspace?: string) => + fetchJSON( + workspace ? `/api/skills?workspace=${encodeURIComponent(workspace)}` : '/api/skills' + ); diff --git a/cptr/frontend/src/lib/apis/terminal.ts b/cptr/frontend/src/lib/apis/terminal.ts index 6f7afafd..e97d7942 100644 --- a/cptr/frontend/src/lib/apis/terminal.ts +++ b/cptr/frontend/src/lib/apis/terminal.ts @@ -25,8 +25,8 @@ export interface CommandSession { export const listSessions = () => fetchJSON('/api/terminal'); -export const createSession = (cwd: string) => - fetchJSON('/api/terminal', jsonBody({ cwd })); +export const createSession = (cwd?: string) => + fetchJSON('/api/terminal', jsonBody(cwd ? { cwd } : {})); export const deleteSession = (sessionId: string) => fetchHandler(`/api/terminal/${sessionId}`, { method: 'DELETE' }).catch(() => {}); diff --git a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte index 93d1986b..f53a8d68 100644 --- a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte @@ -48,7 +48,9 @@ cursor: 'agent', grok: 'grok', opencode: 'opencode', - cline: 'cline' + cline: 'cline', + gemini: 'gemini', + pi: 'pi' }[agent]; } @@ -59,7 +61,9 @@ cursor: 'Cursor', grok: 'Grok', opencode: 'OpenCode', - cline: 'Cline' + cline: 'Cline', + gemini: 'Gemini', + pi: 'Pi' }[agent]; } @@ -129,6 +133,8 @@ + + diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte index a1289929..59477f7e 100644 --- a/cptr/frontend/src/lib/components/Admin/Agents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte @@ -112,7 +112,9 @@ cursor: 'agent', grok: 'grok', opencode: 'opencode', - cline: 'cline' + cline: 'cline', + gemini: 'gemini', + pi: 'pi' }[agent]; } @@ -123,7 +125,9 @@ cursor: 'Cursor', grok: 'Grok', opencode: 'OpenCode', - cline: 'Cline' + cline: 'Cline', + gemini: 'Gemini', + pi: 'Pi' }[agent]; } diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte index cc84ff91..a399ea3c 100644 --- a/cptr/frontend/src/lib/components/Admin/Web.svelte +++ b/cptr/frontend/src/lib/components/Admin/Web.svelte @@ -2,14 +2,26 @@ import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; import { getAdminConfig, updateConfig } from '$lib/apis/admin'; + import { + clearManagedChromeProfile, + connectPersonalChrome, + disconnectPersonalChrome, + getPersonalChrome, + testBrowserCdp, + type PersonalChromeStatus + } from '$lib/apis/browser'; import { t } from '$lib/i18n'; + import { tooltip } from '$lib/tooltip'; + import Collapsible from '$lib/components/Collapsible.svelte'; import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; let loading = $state(true); let saving = $state(false); let testing = $state(false); + let connectingPersonal = $state(false); let testResult = $state<{ ok: boolean; message: string } | null>(null); + let personalStatus = $state(null); // โ”€โ”€ Web search โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let webEnabled = $state(true); @@ -28,6 +40,9 @@ // โ”€โ”€ Browser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let browserEnabled = $state(false); + let browserTabDefaultMode = $state<'proxy' | 'chrome'>('proxy'); + let browserTabChromeSource = $state<'managed' | 'personal'>('managed'); + let personalKeepAlive = $state(true); let browserProvider = $state<'local' | 'firecrawl' | 'browser_use'>('local'); let cdpUrl = $state('http://localhost:9222'); let autoLaunch = $state(true); @@ -36,6 +51,14 @@ let firecrawlBaseUrl = $state('https://api.firecrawl.dev'); let browserUseApiKey = $state(''); let browserUseBaseUrl = $state('https://api.browser-use.com'); + let browserQualityDefault = $state<'low' | 'balanced' | 'crisp'>('balanced'); + let browserQualityProfiles = $state({ + low: { bitrate: 3000000, frame_rate: 15 }, + balanced: { bitrate: 6000000, frame_rate: 24 }, + crisp: { bitrate: 12000000, frame_rate: 30 } + }); + let browserQualityMaxResolution = $state(1080); + let browserQualityMaxBitrate = $state(12000000); onMount(async () => { try { @@ -59,6 +82,10 @@ // Browser browserEnabled = config['browser.enabled'] === true || config['browser.enabled'] === 'true'; + browserTabDefaultMode = config['browser.tab_default_mode'] === 'chrome' ? 'chrome' : 'proxy'; + browserTabChromeSource = + config['browser.tab_chrome_source'] === 'personal' ? 'personal' : 'managed'; + personalKeepAlive = config['browser.personal_keep_alive'] !== false; browserProvider = (config['browser.provider'] as typeof browserProvider) || 'local'; cdpUrl = (config['browser.cdp_url'] as string) || 'http://localhost:9222'; autoLaunch = @@ -70,9 +97,35 @@ browserUseApiKey = (config['browser.browser_use_api_key'] as string) || ''; browserUseBaseUrl = (config['browser.browser_use_base_url'] as string) || 'https://api.browser-use.com'; + if ( + config['browser.quality.default'] === 'low' || + config['browser.quality.default'] === 'crisp' + ) + browserQualityDefault = config['browser.quality.default']; + const qualityProfiles = config['browser.quality.profiles']; + if (qualityProfiles && typeof qualityProfiles === 'object') { + for (const name of ['low', 'balanced', 'crisp'] as const) { + const profile = (qualityProfiles as Record)[name]; + if (profile && typeof profile === 'object') { + const value = profile as Record; + browserQualityProfiles[name] = { + bitrate: Number(value.bitrate) || browserQualityProfiles[name].bitrate, + frame_rate: + Number(value.frame_rate ?? value.fps) || browserQualityProfiles[name].frame_rate + }; + } + } + } + browserQualityMaxResolution = Number(config['browser.quality.max_resolution']) || 1080; + browserQualityMaxBitrate = Number(config['browser.quality.max_bitrate']) || 12000000; } catch { toast.error($t('admin.failedToLoadConfig')); } + try { + personalStatus = await getPersonalChrome(); + } catch { + personalStatus = null; + } loading = false; }); @@ -94,6 +147,9 @@ 'web.chat_completions_base_url': ccBaseUrl, 'web.chat_completions_model': ccModel, 'browser.enabled': browserEnabled, + 'browser.tab_default_mode': browserTabDefaultMode, + 'browser.tab_chrome_source': browserTabChromeSource, + 'browser.personal_keep_alive': personalKeepAlive, 'browser.provider': browserProvider, 'browser.cdp_url': cdpUrl, 'browser.auto_launch': autoLaunch, @@ -101,9 +157,20 @@ 'browser.firecrawl_api_key': firecrawlApiKey, 'browser.firecrawl_base_url': firecrawlBaseUrl, 'browser.browser_use_api_key': browserUseApiKey, - 'browser.browser_use_base_url': browserUseBaseUrl + 'browser.browser_use_base_url': browserUseBaseUrl, + 'browser.quality.default': browserQualityDefault, + 'browser.quality.profiles': browserQualityProfiles, + 'browser.quality.max_resolution': browserQualityMaxResolution, + 'browser.quality.max_bitrate': browserQualityMaxBitrate }; await updateConfig(cfg); + if ( + browserTabDefaultMode !== 'chrome' || + browserTabChromeSource !== 'personal' || + (!personalKeepAlive && personalStatus?.session_count === 0) + ) { + personalStatus = await disconnectPersonalChrome(); + } toast.success($t('settings.saved')); } catch { toast.error($t('admin.failedToSave')); @@ -112,415 +179,674 @@ } } + async function clearManagedProfile() { + if (!confirm($t('admin.managedChromeClearConfirm'))) return; + try { + await clearManagedChromeProfile(); + toast.success($t('admin.managedChromeCleared')); + } catch (error) { + toast.error(error instanceof Error ? error.message : $t('admin.failedToSave')); + } + } + async function testConnection() { testing = true; testResult = null; try { - const resp = await fetch(`${cdpUrl}/json/version`); - if (resp.ok) { - const data = await resp.json(); - testResult = { ok: true, message: data.Browser || 'Connected' }; - } else { - testResult = { ok: false, message: `HTTP ${resp.status}` }; - } - } catch { - testResult = { ok: false, message: 'Could not connect' }; + const result = await testBrowserCdp(cdpUrl); + testResult = { ok: true, message: result.browser }; + } catch (error) { + testResult = { + ok: false, + message: error instanceof Error ? error.message : 'Could not connect' + }; } finally { testing = false; } } - -
-

{$t('admin.web')}

+ async function togglePersonalChrome() { + connectingPersonal = true; + try { + personalStatus = + personalStatus?.status === 'playing' + ? await disconnectPersonalChrome() + : await connectPersonalChrome(cdpUrl); + } catch (error) { + testResult = { + ok: false, + message: error instanceof Error ? error.message : $t('admin.personalChromeUnavailable') + }; + } finally { + connectingPersonal = false; + } + } + +
{#if loading}
{:else} - -

{$t('admin.webSearch')}

+
+

{$t('admin.web')}

-
- -

- {webEnabled ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')} -

+ +

{$t('admin.webSearch')}

- {#if webEnabled} -
- {$t('admin.webSearchProvider')} - -
+
+

- {#if searchProvider === 'auto'} - {$t('admin.webAutoHint')} - {:else if searchProvider === 'duckduckgo'} - {$t('admin.webDuckDuckGoNote')} - {/if} + {webEnabled ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')}

- {#if searchProvider === 'exa'} -
- - -

- {$t('admin.webExaHint')} -

-
- {:else if searchProvider === 'tavily'} -
- - -

- {$t('admin.webTavilyHint')} -

-
- {:else if searchProvider === 'brave'} -
- - -

- {$t('admin.webBraveHint')} -

-
- {:else if searchProvider === 'firecrawl'} -
- - -

- {$t('admin.webFirecrawlHint')} -

-
-
- - -

- {$t('admin.browserFirecrawlBaseUrlHint')} -

-
- {:else if searchProvider === 'searxng'} -
- - -

- {$t('admin.webSearxngHint')} -

-
- {:else if searchProvider === 'perplexity'} -
- - -

- {$t('admin.webPerplexityHint')} -

-
-
- - -

- {$t('admin.webPerplexityBaseUrlHint')} -

-
- {:else if searchProvider === 'chat_completions'} -
- - -
-
- + {$t('admin.webSearchProvider')} - -
-
- - + + + + + + + + + +

- {$t('admin.webCcHint')} + {#if searchProvider === 'auto'} + {$t('admin.webAutoHint')} + {:else if searchProvider === 'duckduckgo'} + {$t('admin.webDuckDuckGoNote')} + {/if}

- {/if} - {/if} -
- - -

{$t('admin.browser')}

- -
- -

- {$t('admin.browserHint')} -

- {#if browserEnabled} -
- {$t('admin.browserProvider')} - -
-

- {#if browserProvider === 'local'} - {$t('admin.browserLocalHint')} - {:else if browserProvider === 'firecrawl'} - {$t('admin.browserFirecrawlHint')} - {:else} - {$t('admin.browserBrowserUseHint')} - {/if} -

- - {#if browserProvider === 'local'} - - -
- -
+ {:else if searchProvider === 'tavily'} +
+ +

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

+
+ {:else if searchProvider === 'brave'} +
+ + +

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

+
+ {:else if searchProvider === 'firecrawl'} +
+ + +

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

+
+
+ + - + {$t('admin.browserFirecrawlBaseUrlHint')} +

+
+ {:else if searchProvider === 'searxng'} +
+ + +

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

- {#if testResult} -

+ - {testResult.message} + +

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

- {/if} -
- -
- -
+
+
+ - {$t('admin.browserMinutes')} + {$t('admin.webPerplexityBaseUrlHint')} +

+
+ {:else if searchProvider === 'chat_completions'} +
+ +
-
- {:else if browserProvider === 'firecrawl'} -
- - -
-
- - -

- {$t('admin.browserFirecrawlBaseUrlHint')} +

+ + +
+
+ + +
+

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

-
- {:else if browserProvider === 'browser_use'} -
- + + +

{$t('admin.browser')}

+ +
+

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

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

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

+
+ + + +
-
- - +
+ {$t('admin.chromeProfile')} +

+ {browserTabChromeSource === 'personal' + ? $t('admin.personalChromeHint') + : $t('admin.managedChromeHint')} +

+
+ +
+ + {#if browserTabChromeSource === 'managed'} +
+
+ {$t('admin.managedChromeData')} +

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

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

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

+
+ +
+ +
+ {#each ['low', 'balanced', 'crisp'] as name} +
+ {name} +
+ + +
+
+ {/each} +
+ {$t('admin.browserQualityMaxHeight')} + + + + +
+
+ {$t('admin.browserQualityMaxBitrate')} + +
+
+
+ {:else} +
+ +
+ + +
+ {#if testResult} +

+ {testResult.message} +

+ {/if} + {$t('admin.personalChromeEnableDebugging')} โ†— +
+
+ {$t('admin.personalChromeConnection')} +

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

+
+ +
+
+ + {#if personalStatus?.status === 'playing'} + {$t('admin.personalChromeConnected')} + {#if personalStatus.session_count} + ยท {personalStatus.session_count} {$t('admin.personalChromeTabs')} + {/if} + {:else if personalStatus?.status === 'lost'} + {$t('admin.personalChromeLost')} + {:else} + {$t('admin.personalChromeDisconnected')} + {/if} + + +
+
+ {/if} + {/if} +
+
+ +
+

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

+
+
- {/if} - {/if} + +

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

+ + {#if browserEnabled} +
+ {$t('admin.browserProvider')} + +
+

+ {#if browserProvider === 'local'} + {$t('admin.browserLocalHint')} + {:else if browserProvider === 'firecrawl'} + {$t('admin.browserFirecrawlHint')} + {:else} + {$t('admin.browserBrowserUseHint')} + {/if} +

+ + {#if browserProvider === 'local'} + + +
+ +
+ + +
+ {#if testResult} +

+ {testResult.message} +

+ {/if} +
+ +
+ +
+ + {$t('admin.browserMinutes')} +
+
+ {:else if browserProvider === 'firecrawl'} +
+ + +
+
+ + +

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

+
+ {:else if browserProvider === 'browser_use'} +
+ + +
+
+ + +
+ {/if} + {/if} +
+
-
+
+ {#if saving}{$t('settings.saving')}{:else}{$t('settings.save')}{/if} +
{/if}
diff --git a/cptr/frontend/src/lib/components/BrowserPreview.svelte b/cptr/frontend/src/lib/components/BrowserPreview.svelte new file mode 100644 index 00000000..7e4868ba --- /dev/null +++ b/cptr/frontend/src/lib/components/BrowserPreview.svelte @@ -0,0 +1,431 @@ + + +
+
+ + + +
+ event.key === 'Enter' && navigate()} + placeholder="https://example.com" + spellcheck="false" + /> + โ†— +
+ {#if mode === 'chrome' && chromeQuality} + + {/if} +
+ {#if qualityMenuOpen && qualityMenuAnchor} + (qualityMenuOpen = false)} + > +
+ {#each [{ value: 'low' as const, label: $t('admin.browserQualityLow') }, { value: 'balanced' as const, label: $t('admin.browserQualityBalanced') }, { value: 'crisp' as const, label: $t('admin.browserQualityCrisp') }] as item} + + {/each} +
+
+ {$t('browser.device')} +
+
+ {#each [{ value: 'auto' as DeviceMode, label: $t('browser.deviceAuto'), icon: 'monitor' }, { value: 'desktop' as DeviceMode, label: $t('browser.deviceDesktop'), icon: 'monitor' }, { value: 'mobile' as DeviceMode, label: $t('browser.deviceMobile'), icon: 'phone' }] as item} + + {/each} +
+ {#if chromeDeviceMode === 'mobile'} + + {#if chromeMobileViewport} +
+ + setChromeMobileDimension('width', event.currentTarget.valueAsNumber)} + class="h-6 min-w-0 rounded-md border border-gray-200 bg-gray-100 px-1 text-center dark:border-white/8 dark:bg-white/6" + /> + ร— + + setChromeMobileDimension('height', event.currentTarget.valueAsNumber)} + class="h-6 min-w-0 rounded-md border border-gray-200 bg-gray-100 px-1 text-center dark:border-white/8 dark:bg-white/6" + /> +
+ {/if} + {/if} +
+
+ {/if} +
+ {#if error} +
+

{error}

+ +
+ {:else if mode === 'chrome'} + { + urlInput = state.url || urlInput; + title = state.title || ''; + updateLabel(title); + canGoBack = state.can_go_back; + canGoForward = state.can_go_forward; + }} + onstatus={(status, message, nextMode) => { + chromeStatus = status; + if (message) modeError = message; + else if (status === 'playing') modeError = ''; + if (nextMode === 'proxy') { + mode = 'proxy'; + chromeQuality = null; + if (urlInput) frameSrc = browserFrameUrl(sessionId, urlInput); + } + }} + onquality={(quality) => (chromeQuality = quality)} + ondevicemode={(nextMode, viewport) => { + chromeDeviceMode = nextMode; + chromeMobileViewport = viewport; + }} + /> + {#if chromeStatus === 'reconnecting'} +
+ + {$t('common.loading')} +
+ {:else if chromeStatus === 'lost'} +
+ {$t('browser.connectionLost')} + {#if modeError}{modeError}{/if} +
+ {/if} + {:else} + + {/if} +
+
+ + diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte new file mode 100644 index 00000000..aa0dc199 --- /dev/null +++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte @@ -0,0 +1,728 @@ + + +
+ + pointer(event, 'move')} + onpointerdown={(event) => pointer(event, 'down')} + onpointerup={(event) => pointer(event, 'up')} + onpointercancel={(event) => pointer(event, 'cancel')} + onwheel={wheel} + onkeydown={(event) => key(event, 'keyDown')} + onkeyup={(event) => key(event, 'keyUp')} + onblur={releaseKeys} + onpaste={(event) => { + event.preventDefault(); + event.stopPropagation(); + send({ type: 'paste', text: event.clipboardData?.getData('text/plain') || '' }); + }} + oncontextmenu={(event) => event.preventDefault()} + > +
+ + diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte index 04277e7d..655e85fa 100644 --- a/cptr/frontend/src/lib/components/FileBrowser.svelte +++ b/cptr/frontend/src/lib/components/FileBrowser.svelte @@ -15,7 +15,10 @@ deleteFiles, moveFile, uploadFiles as apiUpload, - createEntry + createEntry, + getFileMatches, + type FileMatch, + type ContentMatch } from '$lib/apis/files'; import { fileIconName } from '$lib/utils/fileIcon'; import Icon from './Icon.svelte'; @@ -44,6 +47,16 @@ let dragExpandTimer: ReturnType | null = null; let error = $state(null); let searchQuery = $state(''); + let matchResults = $state(null); + let matchLoading = $state(false); + let matchLoadingMore = $state(false); + let matchError = $state(null); + let matchLoadMoreError = $state(false); + let nextMatchOffset = $state(null); + let matchTimer: ReturnType | null = null; + let matchController: AbortController | null = null; + let matchRequestId = 0; + let searchTargetRequestId = 0; let dragOverDir = $state(null); let draggedItem = $state(null); let showNewInput = $state<'file' | 'folder' | null>(null); @@ -80,6 +93,10 @@ let cwd = $derived($activeWorkspace?.fileBrowserCwd ?? $activeWorkspace?.path ?? '/'); let workspacePath = $derived($activeWorkspace?.path ?? '/'); + let searchText = $derived(searchQuery.trim()); + let isSearching = $derived(Boolean(searchText)); + let filenameMatches = $derived(matchResults?.filter((match) => match.name_match) ?? []); + let contentOnlyMatches = $derived(matchResults?.filter((match) => !match.name_match) ?? []); let gitStatus = $derived(gitStatusStore.status); let gitRoot = $derived(workspacePath.replace(/\/$/, '')); @@ -283,6 +300,7 @@ const isNavigation = _prevCwd !== ''; _prevCwd = cwd; if (isNavigation) { + searchQuery = ''; // Navigated to a new directory: reset tree state expandedDirs = new Set(); dirContents = new Map(); @@ -300,6 +318,97 @@ } }); + $effect(() => { + const query = searchText; + const hidden = showHidden; + if (matchTimer) clearTimeout(matchTimer); + matchTimer = null; + matchController?.abort(); + matchController = null; + matchError = null; + const requestId = ++matchRequestId; + + if (!query) { + matchResults = null; + matchLoading = false; + matchLoadingMore = false; + matchLoadMoreError = false; + nextMatchOffset = null; + return; + } + + showNewInput = null; + clearSelection(); + contextMenu = null; + directoryMenu = null; + addMenuOpen = false; + sortMenuOpen = false; + matchLoading = true; + matchLoadingMore = false; + matchLoadMoreError = false; + nextMatchOffset = null; + matchTimer = setTimeout(async () => { + const controller = new AbortController(); + matchController = controller; + try { + const data = await getFileMatches(query, cwd, hidden, 0, controller.signal); + if (requestId === matchRequestId) { + matchResults = data.results; + nextMatchOffset = data.next_offset; + } + } catch (e: any) { + if (e.name !== 'AbortError' && requestId === matchRequestId) { + matchError = e.message || $t('files.failedToSearch'); + matchResults = []; + } + } finally { + if (requestId === matchRequestId) matchLoading = false; + } + }, 200); + }); + + async function loadMoreMatches() { + const offset = nextMatchOffset; + if (offset === null || !isSearching || matchLoading || matchLoadingMore || matchLoadMoreError) + return; + + const query = searchText; + const hidden = showHidden; + const requestId = matchRequestId; + matchLoadingMore = true; + const controller = new AbortController(); + matchController = controller; + try { + const data = await getFileMatches(query, cwd, hidden, offset, controller.signal); + if (requestId === matchRequestId) { + matchResults = [...(matchResults ?? []), ...data.results]; + nextMatchOffset = data.next_offset; + } + } catch (e: any) { + if (e.name !== 'AbortError' && requestId === matchRequestId) { + matchLoadMoreError = true; + } + } finally { + if (requestId === matchRequestId) matchLoadingMore = false; + } + } + + function retryMoreMatches() { + matchLoadMoreError = false; + void loadMoreMatches(); + } + + function loadMoreOnVisible(node: HTMLElement) { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) void loadMoreMatches(); + }, + { rootMargin: '160px' } + ); + observer.observe(node); + return { destroy: () => observer.disconnect() }; + } + // Keep the websocket watch path in sync (separate effect so it doesn't // re-trigger directory fetches via fsTick feedback loops) $effect(() => { @@ -464,9 +573,34 @@ } function navigateTo(path: string) { + searchQuery = ''; setFileBrowserCwd(path); } + function parentPath(relativePath: string): string { + const slash = relativePath.lastIndexOf('/'); + return slash === -1 ? '' : relativePath.slice(0, slash); + } + + function openFileMatch(match: FileMatch) { + if (match.type === 'directory') { + navigateTo(match.path); + return; + } + openFileTab(match.path); + } + + function openContentMatch(match: FileMatch, contentMatch: ContentMatch) { + openFileTab(match.path, undefined, { + searchTarget: { + line: contentMatch.line, + column: contentMatch.column, + length: searchText.length, + requestId: ++searchTargetRequestId + } + }); + } + // fileIconName imported from $lib/utils/fileIcon function formatSize(bytes: number | null): string { @@ -955,9 +1089,9 @@
!isSearching && onDropzoneOver(e)} + ondragleave={(e) => !isSearching && onDropzoneLeave(e)} + ondrop={(e) => !isSearching && onDropzoneDrop(e)} >
- - + {#if !isSearching} + + - - + + + {/if}
@@ -1051,7 +1187,7 @@ {#if searchQuery} @@ -1064,9 +1200,12 @@ -
+
!isSearching && onDirectoryContextMenu(e)} + > - {#if showNewInput} + {#if showNewInput && !isSearching}
fetchDirectory(cwd)}>{$t('files.retry')}
+ {:else if isSearching} + {#if matchLoading} +
+ +
+ {:else if matchError} +
+

{matchError}

+
+ {:else if !matchResults?.length} +
+

{$t('files.noMatches')}

+
+ {:else} + {#snippet resultRows(matches: FileMatch[])} + {#each matches as match (match.path)} + + {#if match.content_matches.length > 0} + {@const preview = match.content_matches[0]} + + {/if} + {/each} + {/snippet} + + {#if filenameMatches.length > 0} +
+ {$t('files.filenameMatches')} +
+ {@render resultRows(filenameMatches)} + {/if} + + {#if contentOnlyMatches.length > 0} +
+ {$t('files.contentMatches')} +
+ {@render resultRows(contentOnlyMatches)} + {/if} + {#if nextMatchOffset !== null} +
+ {#if matchLoadingMore} + + {:else if matchLoadMoreError} + + {/if} +
+ {/if} + {/if} {:else if visibleEntries.length === 0 && !showNewInput}

@@ -1157,7 +1387,7 @@ : null} - - - - - - - - -

- -
- - - -
- - - {#if isLoading} -
-
-
- {/if} - - -
- {#if loadError} -
-

{$t('port.cannotConnect')}

-

{$t('port.notResponding', { port })}

- -
- {:else} - {#key iframeKey} - - {/key} - {/if} -
-
- - diff --git a/cptr/frontend/src/lib/components/SearchModal.svelte b/cptr/frontend/src/lib/components/SearchModal.svelte index 8117505f..172f5837 100644 --- a/cptr/frontend/src/lib/components/SearchModal.svelte +++ b/cptr/frontend/src/lib/components/SearchModal.svelte @@ -58,7 +58,7 @@ async function loadRecents() { try { - const data = await getRecentChats(9); + const data = await getRecentChats(9, effectiveWorkspace?.path); recentChats = data.chats; } catch { // ignore diff --git a/cptr/frontend/src/lib/components/Settings/Appearance.svelte b/cptr/frontend/src/lib/components/Settings/Appearance.svelte index 0e4a58c7..0379e9de 100644 --- a/cptr/frontend/src/lib/components/Settings/Appearance.svelte +++ b/cptr/frontend/src/lib/components/Settings/Appearance.svelte @@ -2,7 +2,8 @@ import { toast } from 'svelte-sonner'; import Icon from '../Icon.svelte'; import { t } from '$lib/i18n'; - import { textScale, theme, themeConfig } from '$lib/stores'; + import { expandToolDetails, textScale, theme, themeConfig, widescreenMode } from '$lib/stores'; + import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte'; import type { Theme, ThemeConfig } from '$lib/stores'; import { normalizeHexColor, @@ -21,7 +22,9 @@ const resolvedTheme = $derived(resolveThemeMode($theme)); const resolvedConfig = $derived(resolveThemeConfig($theme, $themeConfig)); - const hasCustomAppearance = $derived(Boolean($themeConfig || $textScale !== null)); + const hasCustomAppearance = $derived( + Boolean($themeConfig || $textScale !== null || $widescreenMode) + ); $effect(() => { colorDrafts = { @@ -102,13 +105,15 @@ scaleEnabled = false; scaleDraft = 1; textScale.set(null); + widescreenMode.set(false); } function exportTheme() { const payload = { theme: $theme, themeConfig: sanitizeThemeConfig($themeConfig), - textScale: $textScale + textScale: $textScale, + widescreenMode: $widescreenMode }; try { @@ -155,13 +160,21 @@ typeof parsed?.textScale === 'number' && Number.isFinite(parsed.textScale) ? normalizeTextScale(parsed.textScale) : undefined; - - if (!importedConfig && !importedTheme && importedScale === undefined) { + const importedWidescreenMode = + typeof parsed?.widescreenMode === 'boolean' ? parsed.widescreenMode : undefined; + + if ( + !importedConfig && + !importedTheme && + importedScale === undefined && + importedWidescreenMode === undefined + ) { throw new Error('empty theme'); } if (importedTheme) theme.set(importedTheme); if (importedConfig) themeConfig.set(importedConfig); if (importedScale !== undefined) textScale.set(importedScale === 1 ? null : importedScale); + if (importedWidescreenMode !== undefined) widescreenMode.set(importedWidescreenMode); toast.success($t('appearance.imported')); } catch { toast.error($t('appearance.importFailed')); @@ -262,10 +275,20 @@ /> -

- {$t('general.uiScale')} -

-
+ + + + +
{$t('general.uiScale')} diff --git a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte index 3256884d..100ffbad 100644 --- a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte +++ b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte @@ -16,6 +16,7 @@ newFile: $t('keyboard.newFile'), newTerminal: $t('keyboard.newTerminal'), newChat: $t('keyboard.newChat'), + newBrowser: $t('keyboard.newBrowser'), closeTab: $t('keyboard.closeTab'), nextTab: $t('keyboard.nextTab'), prevTab: $t('keyboard.prevTab'), @@ -30,6 +31,7 @@ newFile: $t('keyboard.action.newFile'), newTerminal: $t('keyboard.action.newTerminal'), newChat: $t('keyboard.action.newChat'), + newBrowser: $t('keyboard.action.newBrowser'), closeTab: $t('keyboard.action.closeTab'), nextTab: $t('keyboard.action.nextTab'), prevTab: $t('keyboard.action.prevTab'), diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index 98d3a235..99022a78 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -4,7 +4,12 @@ import { workspaceList, removeWorkspace, reorderWorkspaces, sidebarOpen } from '$lib/stores'; import { chatEnabled } from '$lib/stores/chat'; import { socketStore } from '$lib/stores/socket.svelte'; - import { deleteChat as apiDeleteChat, getChats, type ChatInfo } from '$lib/apis/chat'; + import { + deleteChat as apiDeleteChat, + getChats, + updateChatTitle, + type ChatInfo + } from '$lib/apis/chat'; import { t } from '$lib/i18n'; import { tooltip } from '$lib/tooltip'; import Sortable from 'sortablejs'; @@ -24,9 +29,11 @@ let wsListEl: HTMLDivElement | undefined = $state(); let sortable: Sortable | null = null; let unbindSocketListener: (() => void) | null = null; + let workspacesExpanded = $state(true); let expandedWorkspaces = $state>(new Set()); let wsChatsCache = $state>(new Map()); + let wsChatsHasMore = $state>(new Map()); let wsChatsLoading = $state>(new Set()); let currentPath = $derived($page.url.searchParams.get('workspace')); @@ -41,14 +48,20 @@ expandedWorkspaces = next; } - async function fetchWorkspaceChats(path: string) { + async function fetchWorkspaceChats(path: string, append = false) { if (wsChatsLoading.has(path)) return; wsChatsLoading = new Set([...wsChatsLoading, path]); try { - const data = await getChats(path, 5, 0, 'updated_at', 'desc'); - wsChatsCache = new Map([...wsChatsCache, [path, data.chats || []]]); + const existing = wsChatsCache.get(path) ?? []; + const data = await getChats(path, 5, append ? existing.length : 0, 'updated_at', 'desc'); + wsChatsCache = new Map([ + ...wsChatsCache, + [path, append ? [...existing, ...(data.chats || [])] : data.chats || []] + ]); + wsChatsHasMore = new Map([...wsChatsHasMore, [path, data.has_more]]); } catch { wsChatsCache = new Map([...wsChatsCache, [path, []]]); + wsChatsHasMore = new Map([...wsChatsHasMore, [path, false]]); } finally { const next = new Set(wsChatsLoading); next.delete(path); @@ -72,11 +85,6 @@ closeMobileSidebar(); } - function showMoreChats(wsPath: string) { - goto(`/?workspace=${encodeURIComponent(wsPath)}&chatId`); - closeMobileSidebar(); - } - function newChat(wsPath: string) { goto(`/?workspace=${encodeURIComponent(wsPath)}&chatId`); closeMobileSidebar(); @@ -123,6 +131,30 @@ } } + async function handleRenameChat() { + if (!chatMenu) return; + const { chatId, wsPath } = chatMenu; + const chat = (wsChatsCache.get(wsPath) ?? []).find((item) => item.id === chatId); + const title = window.prompt($t('files.rename'), chat?.title)?.trim(); + if (!title || title === chat?.title) return; + await updateChatTitle(chatId, title); + const chats = wsChatsCache.get(wsPath) ?? []; + wsChatsCache = new Map([ + ...wsChatsCache, + [wsPath, chats.map((item) => (item.id === chatId ? { ...item, title } : item))] + ]); + } + + function copyChatPath() { + if (!chatMenu) return; + const { chatId, wsPath } = chatMenu; + const chat = (wsChatsCache.get(wsPath) ?? []).find((item) => item.id === chatId); + if (!chat) return; + navigator.clipboard.writeText( + `${wsPath.replace(/\/$/, '')}/.cptr/chats/${chat.folder ? `${chat.folder}/` : ''}${chat.id}.json` + ); + } + const seenChatIds = new Set(); function handleChatEvent(data: { @@ -137,6 +169,7 @@ if (!data.done && !data.title && !isNew) return; wsChatsCache = new Map(); + wsChatsHasMore = new Map(); for (const path of expandedWorkspaces) fetchWorkspaceChats(path); } @@ -172,7 +205,20 @@
- {$t('sidebar.workspaces')} +
-
+
{#each $workspaceList as ws (ws.path)} {@const isExpanded = expandedWorkspaces.has(ws.path)} {@const chats = wsChatsCache.get(ws.path)} + {@const hasMoreChats = wsChatsHasMore.get(ws.path)} {@const isLoading = wsChatsLoading.has(ws.path)}
openChatMenu(e, chat.id, ws.path)} /> {/each} - + {#if hasMoreChats} + + {/if} {/if}
{/if} @@ -301,6 +359,16 @@ anchor={chatMenu.anchor} align="end" items={[ + { + label: $t('files.copyPath'), + icon: 'copy', + onclick: copyChatPath + }, + { + label: $t('files.rename'), + icon: 'pencil', + onclick: handleRenameChat + }, { label: $t('chat.history.delete'), icon: 'trash', @@ -347,7 +415,7 @@ } :global(.dark) .ws-icon-chevron { - color: #6b7280; + color: var(--app-fg-muted); } .ws-icon-toggle:hover .ws-icon-folder { @@ -371,21 +439,13 @@ background: none; cursor: pointer; font-size: 0.6875rem; - color: #b0b5be; + color: var(--app-fg-subtle); text-align: left; transition: color 0.1s; } .ws-chat-show-more:hover { - color: #6b7280; - } - - :global(.dark) .ws-chat-show-more { - color: #6b7280; - } - - :global(.dark) .ws-chat-show-more:hover { - color: #9ca3af; + color: var(--app-fg); } .ws-chat-loading { diff --git a/cptr/frontend/src/lib/components/chat/ChatHistory.svelte b/cptr/frontend/src/lib/components/chat/ChatHistory.svelte index 127bd7a0..792b70ea 100644 --- a/cptr/frontend/src/lib/components/chat/ChatHistory.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatHistory.svelte @@ -9,6 +9,8 @@ chats: ChatInfo[]; onopen: (id: string) => void; ondelete: (id: string) => void; + onrename: (id: string) => void; + oncopy?: (id: string) => void; page?: number; totalPages?: number; perPage?: number; @@ -21,6 +23,8 @@ chats, onopen, ondelete, + onrename, + oncopy, page = 1, totalPages = 1, perPage = 10, @@ -144,6 +148,24 @@ anchor={menuAnchor} align="end" items={[ + ...(oncopy + ? [ + { + label: $t('files.copyPath'), + icon: 'copy', + onclick: () => { + if (menuChatId) oncopy(menuChatId); + } + } + ] + : []), + { + label: $t('files.rename'), + icon: 'pencil', + onclick: () => { + if (menuChatId) onrename(menuChatId); + } + }, { label: $t('chat.history.delete'), icon: 'trash', diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index d4e8d32a..23c48f0f 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -160,6 +160,7 @@ } async function processFiles(files: File[]) { + if (!workspace) return; for (const file of files) { const id = Math.random().toString(36).substring(7); const isImage = file.type.startsWith('image/'); @@ -373,7 +374,6 @@ let slashSkillsRequestId = 0; async function getCachedSkills(): Promise { - if (!workspace) return []; if (!cachedSkills || cachedSkillsWorkspace !== workspace) { const data = await getSkills(workspace); cachedSkills = data.map((s) => ({ @@ -388,7 +388,6 @@ } async function fetchSkillSuggestions({ query }: { query: string }): Promise { - if (!workspace) return []; try { const skills = await getCachedSkills(); if (!query) return skills; @@ -990,7 +989,7 @@ $effect(() => { const query = slashCommandQuery; const requestId = ++slashSkillsRequestId; - if (!query.startsWith('/') || /\s/.test(query.slice(1)) || !workspace) { + if (!query.startsWith('/') || /\s/.test(query.slice(1))) { slashSkillSuggestions = []; return; } @@ -1472,14 +1471,16 @@ onmousedown={(e) => e.stopPropagation()} >
- { - if (files) processFiles(Array.from(files)); - }} - oncapture={(file) => { - processFiles([file]); - }} - /> + {#if workspace} + { + if (files) processFiles(Array.from(files)); + }} + oncapture={(file) => { + processFiles([file]); + }} + /> + {/if} {#if $planMode} - {/if} -
- {#if welcomeData?.hostname} -

- {welcomeData.hostname} -

- {/if} -
- -
-

{$t('home.start')}

- -
- - {#if continuation} -
-

{$t('home.continue')}

- -
- {/if} - - {#if recent.length} -
-

{$t('home.recent')}

-
- {#each recent as item} - {@const resume = workspaceResumes.get(item.path)} - {@const signals = resumeSignals(resume)} - + {/if} +
+
+ +
+

+ {$t('home.start')} +

+ + {#if $chatEnabled} +
+ + {#if continuation} +
+

+ {$t('home.continue')} +

+ +
+ {/if} + + {#if recent.length} +
+

+ {$t('home.recent')} +

+
+ {#each recent as item} + {@const resume = workspaceResumes.get(item.path)} + {@const signals = resumeSignals(resume)} + + {/each} +
+
+ {:else if !continuation} +
+

+ {$t('home.recent')} +

+ - {/each} + {$t('home.noWorkspaces')} + +
+ {/if} + + {#if nearby.length && !welcomeData?.recent?.length} +
+

+ {$t('home.folders')} +

+
+ {#each nearby as item} + + {/each} +
+
+ {/if} +
-
- {:else if !continuation} -
-

{$t('home.recent')}

- -
+ {:else if homeTab?.type === 'chat'} + + updateHomeChatTab(tabId, chatId, label, homePane.id)} + onopenchat={(chatId) => openHomeChat(chatId, homePane.id)} + /> + {:else if homeTab?.type === 'terminal' && homeTab.sessionId} + + {:else if homeTab?.type === 'browser' && homeTab.browserSessionId} + updateHomeBrowserTab(homeTab.id, label, homePane.id)} + onOpenBrowser={(url) => openHomeBrowser(url, homePane.id)} + /> + {/if} +
+ {#if dragOverZone?.groupId === homePane.id} +
{/if} +
+ {/snippet} - {#if nearby.length && !welcomeData?.recent?.length} -
-

{$t('home.folders')}

-
- {#each nearby as item} - - {/each} -
+ {#snippet renderHomeLayout(node: EditorLayout)} + {#if node.type === 'group'} + {@const group = $homeState.groups.find((item) => item.id === node.groupId)} + {#if group}{@render renderHomePane(group)}{/if} + {:else} +
+
+ {@render renderHomeLayout(node.first)}
- {/if} -
+ +
handleDividerPointerDown(event, node)} + onpointermove={handleDividerPointerMove} + onpointerup={handleDividerPointerUp} + onpointercancel={handleDividerPointerUp} + >
+
+ {@render renderHomeLayout(node.second)} +
+
+ {/if} + {/snippet} + + {:else} -