diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc4be5..68629ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.2] - 2026-07-11 + +### Added + +- ๐Ÿ’ฌ **Helpful planning questions.** When a plan needs your input, Computer can now ask a few focused questions with clear choices and a recommended answer. + +### Changed + +- ๐Ÿง  **Each chat remembers its own choices.** Your selected model and chat options now stay with that conversation instead of carrying over from another one. +- ๐Ÿ  **A more capable Home.** Open files from conversations while using Home, and keep those file tabs available as you move between tabs. +- ๐ŸŽจ **A more consistent look.** Your appearance choice now carries through to browsers, terminals, editors, previews, menus, and other everyday parts of the app. + ## [0.9.1] - 2026-07-11 ### Fixed diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index bf7d6c4..c29a90c 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -8,19 +8,19 @@ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - /* Exact Open WebUI gray scale - pure neutral, zero chroma */ - --color-gray-50: oklch(0.98 0 0); - --color-gray-100: oklch(0.94 0 0); - --color-gray-200: oklch(0.92 0 0); - --color-gray-300: oklch(0.85 0 0); - --color-gray-400: oklch(0.77 0 0); - --color-gray-500: oklch(0.69 0 0); - --color-gray-600: oklch(0.51 0 0); - --color-gray-700: oklch(0.42 0 0); - --color-gray-800: oklch(0.32 0 0); - --color-gray-850: oklch(0.27 0 0); - --color-gray-900: oklch(0.2 0 0); - --color-gray-950: oklch(0.16 0 0); + /* Keep Tailwind's neutral utilities on the selected appearance. */ + --color-gray-50: color-mix(in oklab, var(--app-bg) 96%, var(--app-fg)); + --color-gray-100: color-mix(in oklab, var(--app-bg) 92%, var(--app-fg)); + --color-gray-200: color-mix(in oklab, var(--app-bg) 85%, var(--app-fg)); + --color-gray-300: color-mix(in oklab, var(--app-bg) 75%, var(--app-fg)); + --color-gray-400: color-mix(in oklab, var(--app-bg) 65%, var(--app-fg)); + --color-gray-500: color-mix(in oklab, var(--app-bg) 55%, var(--app-fg)); + --color-gray-600: color-mix(in oklab, var(--app-bg) 45%, var(--app-fg)); + --color-gray-700: color-mix(in oklab, var(--app-bg) 35%, var(--app-fg)); + --color-gray-800: color-mix(in oklab, var(--app-bg) 25%, var(--app-fg)); + --color-gray-850: color-mix(in oklab, var(--app-bg) 20%, var(--app-fg)); + --color-gray-900: color-mix(in oklab, var(--app-bg) 15%, var(--app-fg)); + --color-gray-950: color-mix(in oklab, var(--app-bg) 10%, var(--app-fg)); } :root { @@ -76,19 +76,12 @@ /* Scrollbar - Open WebUI style */ ::-webkit-scrollbar-thumb { - --tw-border-opacity: 1; - background-color: rgba(215, 215, 215, 0.6); - border-color: rgba(255, 255, 255, var(--tw-border-opacity)); + background-color: color-mix(in oklab, var(--app-fg) 32%, transparent); + border-color: var(--app-bg); border-radius: 624.9375rem; border-width: 1px; } -/* Dark theme scrollbar styles */ -.dark ::-webkit-scrollbar-thumb { - background-color: rgba(67, 67, 67, 0.6); - border-color: rgba(0, 0, 0, var(--tw-border-opacity)); -} - ::-webkit-scrollbar { height: 0.45rem; width: 0.45rem; @@ -293,20 +286,20 @@ } .dark .cm-editor { - background-color: #000 !important; + background-color: var(--app-bg) !important; } .dark .cm-gutters { - background-color: #000 !important; + background-color: var(--app-bg) !important; border: none !important; } .dark .cm-activeLine { - background-color: rgba(255, 255, 255, 0.04) !important; + background-color: var(--app-hover) !important; } .dark .cm-activeLineGutter { - background-color: rgba(255, 255, 255, 0.04) !important; + background-color: var(--app-hover) !important; } :not(.dark) .cm-gutters { @@ -338,45 +331,33 @@ .shimmer { background: linear-gradient( 110deg, - #b4b4b4 0%, - #b4b4b4 43%, - #e8e8e8 50%, - #b4b4b4 57%, - #b4b4b4 100% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; - color: #b4b4b4; -} - -.dark .shimmer { - background: linear-gradient( - 110deg, - #9a9a9a 0%, - #9a9a9a 43%, - #5e5e5e 50%, - #9a9a9a 57%, - #9a9a9a 100% + var(--app-fg-subtle) 0%, + var(--app-fg-subtle) 43%, + var(--app-fg) 50%, + var(--app-fg-subtle) 57%, + var(--app-fg-subtle) 100% ); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; - color: #9a9a9a; + color: var(--app-fg-subtle); } /* Inline code (codespan), Open WebUI style */ .codespan { padding: 0.15rem 0.3rem; font-size: 0.85em; - @apply font-mono rounded-md text-gray-800 bg-gray-100 dark:text-gray-200 dark:bg-gray-800 mx-0.5; + @apply font-mono rounded-md mx-0.5; + background: var(--app-hover); + color: var(--app-fg); } /* Tippy tooltip theme */ .tippy-box[data-theme~='cptr'] { - @apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl; + @apply rounded-lg text-xs border shadow-xl; + background: var(--app-bg); + color: var(--app-fg); + border-color: var(--app-border); } diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 6914f37..464513c 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -59,8 +59,10 @@ export interface SendMessageResult { assistant_message?: ChatMessageRow; } +export type ToolApprovalMode = 'ask' | 'auto' | 'full'; + export interface ChatSendParams { - tool_approval_mode?: string; + tool_approval_mode?: ToolApprovalMode; plan_mode?: boolean; request_params?: Record; voice_mode?: boolean; @@ -104,6 +106,12 @@ export const updateChatTitle = (chatId: string, title: string) => body: JSON.stringify({ title }) }); +export const updateChatSettings = (chatId: string, modelId: string, params: ChatSendParams) => + fetchJSON<{ ok: boolean }>( + `/api/chats/${chatId}/settings`, + jsonBody({ model_id: modelId, params }) + ); + export const forkChat = (chatId: string, messageId?: string | null) => fetchJSON<{ ok: boolean; chat_id: string }>( `/api/chats/${chatId}/fork`, @@ -147,6 +155,18 @@ export const approveToolCall = ( jsonBody({ call_id: callId, approved }) ); +export const answerAskUser = ( + chatId: string, + messageId: string, + callId: string, + answers: Record, + timedOut = false +) => + fetchJSON( + `/api/chats/${chatId}/messages/${messageId}/answer`, + jsonBody({ call_id: callId, answers, timed_out: timedOut }) + ); + export const cancelTask = (chatId: string, messageId: string) => fetchJSON(`/api/chats/${chatId}/messages/${messageId}/cancel`, { method: 'POST' }); diff --git a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte index d96873d..52fe31a 100644 --- a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte @@ -7,7 +7,8 @@ import Icon from '../Icon.svelte'; import DropdownMenu from '../DropdownMenu.svelte'; import ModelSelector from '../common/ModelSelector.svelte'; - import { selectedModelId, workspaceList } from '$lib/stores'; + import { workspaceList } from '$lib/stores'; + import { defaultModel } from '$lib/stores/chat'; import { getPathDisplayName } from '$lib/utils/paths'; import { createBot, updateBot, verifyBotToken, type BotData, type BotForm } from '$lib/apis/bots'; import { toast } from 'svelte-sonner'; @@ -25,7 +26,7 @@ let platform = $state(bot?.platform || 'telegram'); let name = $state(bot?.name || ''); let token = $state(''); - let modelId = $state(bot?.model_id || $selectedModelId || ''); + let modelId = $state(bot?.model_id || $defaultModel || ''); let workspace = $state(bot?.workspace || ''); let allowedSenders = $state(bot?.allowed_senders?.join(', ') || ''); let saving = $state(false); diff --git a/cptr/frontend/src/lib/components/BrowserPreview.svelte b/cptr/frontend/src/lib/components/BrowserPreview.svelte index 7e4868b..12e4290 100644 --- a/cptr/frontend/src/lib/components/BrowserPreview.svelte +++ b/cptr/frontend/src/lib/components/BrowserPreview.svelte @@ -416,7 +416,7 @@ height: 100%; border: 0; display: block; - background: white; + background: var(--app-bg); } .preview-error { height: 100%; diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte index aa0dc19..ea59939 100644 --- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte +++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte @@ -699,7 +699,7 @@ width: 100%; height: 100%; display: block; - background: white; + background: var(--app-bg); } .chrome-container { position: relative; diff --git a/cptr/frontend/src/lib/components/FileEditor.svelte b/cptr/frontend/src/lib/components/FileEditor.svelte index da5a68e..bbdf9f7 100644 --- a/cptr/frontend/src/lib/components/FileEditor.svelte +++ b/cptr/frontend/src/lib/components/FileEditor.svelte @@ -955,16 +955,17 @@ '&': { height: '100%', fontSize: '0.8125rem', - backgroundColor: dark ? '#000' : '#ffffff' + backgroundColor: 'var(--app-bg)', + color: 'var(--app-fg)' }, '.cm-scroller': { fontFamily: '"JetBrains Mono", "Fira Code", ui-monospace, monospace' }, '.cm-gutters': { - backgroundColor: dark ? '#000' : '#ffffff', - color: dark ? '#555' : '#999', + backgroundColor: 'var(--app-bg)', + color: 'var(--app-fg-subtle)', border: 'none' }, - '.cm-activeLineGutter': { backgroundColor: dark ? '#1a1a1a' : '#f3f4f6' }, - '.cm-activeLine': { backgroundColor: dark ? '#1a1a1a' : '#f9fafb' }, + '.cm-activeLineGutter': { backgroundColor: 'var(--app-hover)' }, + '.cm-activeLine': { backgroundColor: 'var(--app-hover)' }, '.cm-lineNumbers .cm-gutterElement.cm-git-line-added': { boxShadow: `inset 0.125rem 0 0 ${dark ? 'rgba(52, 211, 153, 0.75)' : 'rgba(5, 150, 105, 0.65)'}` }, @@ -1306,8 +1307,8 @@ flex-shrink: 0; } - :global(.dark) .toolbar { - border-bottom-color: rgba(255, 255, 255, 0.06); + .toolbar { + border-bottom-color: var(--app-border); } .toolbar-left { @@ -1353,7 +1354,7 @@ } :global(.dark) .file-name { - color: var(--color-gray-300); + color: var(--app-fg); } .file-git-stats { @@ -1436,7 +1437,7 @@ } :global(.dark) .toolbar-btn.active { - color: white; + color: var(--app-fg); } .editor-area { @@ -1463,7 +1464,7 @@ max-width: 100%; max-height: 100%; border-radius: 0.5rem; - background: #000; + background: var(--app-bg); } .media-container audio { diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index e7160fd..b675859 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -1,6 +1,7 @@ {text} + + diff --git a/cptr/frontend/src/lib/components/Settings/Appearance.svelte b/cptr/frontend/src/lib/components/Settings/Appearance.svelte index 0379e9d..34af60c 100644 --- a/cptr/frontend/src/lib/components/Settings/Appearance.svelte +++ b/cptr/frontend/src/lib/components/Settings/Appearance.svelte @@ -348,7 +348,7 @@ .appearance-swatch { width: 1.5rem; height: 1.5rem; - border: 1px solid rgb(156 163 175 / 0.35); + border: 1px solid var(--app-border); border-radius: 624.9375rem; background: transparent; padding: 0.125rem; @@ -364,7 +364,7 @@ .ui-scale-range::-webkit-slider-runnable-track { height: 0.125rem; border-radius: 624.9375rem; - background: rgb(209 213 219 / 0.7); + background: var(--app-divider); } .ui-scale-range::-webkit-slider-thumb { @@ -373,32 +373,22 @@ height: 0.75rem; margin-top: -0.3125rem; border-radius: 624.9375rem; - border: 1px solid rgb(156 163 175 / 0.45); - background: rgb(255 255 255); + border: 1px solid var(--app-border); + background: var(--app-bg); } .ui-scale-range::-moz-range-track { height: 0.125rem; border-radius: 624.9375rem; - background: rgb(209 213 219 / 0.7); + background: var(--app-divider); } .ui-scale-range::-moz-range-thumb { width: 0.75rem; height: 0.75rem; border-radius: 624.9375rem; - border: 1px solid rgb(156 163 175 / 0.45); - background: rgb(255 255 255); + border: 1px solid var(--app-border); + background: var(--app-bg); } - :global(.dark) .ui-scale-range::-webkit-slider-runnable-track, - :global(.dark) .ui-scale-range::-moz-range-track { - background: rgb(255 255 255 / 0.12); - } - - :global(.dark) .ui-scale-range::-webkit-slider-thumb, - :global(.dark) .ui-scale-range::-moz-range-thumb { - border-color: rgb(255 255 255 / 0.18); - background: rgb(229 231 235); - } diff --git a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte index 100ffba..b6517f8 100644 --- a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte +++ b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte @@ -168,7 +168,7 @@ .shortcut-row { @apply flex items-center gap-2 px-1 py-1.5; - border-bottom: 1px solid rgba(128, 128, 128, 0.04); + border-bottom: 1px solid var(--app-divider); } .shortcut-row:last-child { @@ -183,8 +183,8 @@ border-radius: 0.25rem; font-size: 0.625rem; font-weight: 500; - background: rgba(59, 130, 246, 0.1); - color: rgba(59, 130, 246, 0.7); + background: var(--app-active); + color: var(--app-fg); animation: pulse-subtle 1.5s ease-in-out infinite; } diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte index 5f9d803..2673f4e 100644 --- a/cptr/frontend/src/lib/components/Settings/Notifications.svelte +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -463,7 +463,7 @@ .notification-target-row { @apply flex items-center gap-3 px-1 py-1.5; - border-bottom: 1px solid rgba(128, 128, 128, 0.04); + border-bottom: 1px solid var(--app-divider); } .notification-target-row:last-child { diff --git a/cptr/frontend/src/lib/components/ShortcutBar.svelte b/cptr/frontend/src/lib/components/ShortcutBar.svelte index 1da2eff..3ef8882 100644 --- a/cptr/frontend/src/lib/components/ShortcutBar.svelte +++ b/cptr/frontend/src/lib/components/ShortcutBar.svelte @@ -224,18 +224,13 @@ align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; - background: var(--color-gray-100); - border-top: 1px solid var(--color-gray-200); + background: var(--app-bg); + border-top: 1px solid var(--app-border); overflow-x: auto; flex-shrink: 0; scrollbar-width: none; } - :global(.dark) .shortcut-bar { - background: #0a0a0a; - border-top-color: rgba(255, 255, 255, 0.06); - } - .shortcut-bar::-webkit-scrollbar { display: none; } @@ -246,8 +241,8 @@ font-family: var(--font-mono); font-weight: 500; border-radius: 0.3125rem; - color: var(--color-gray-600); - background: var(--color-gray-200); + color: var(--app-fg-muted); + background: var(--app-hover); white-space: nowrap; flex-shrink: 0; transition: all 0.1s ease; @@ -256,22 +251,12 @@ -webkit-tap-highlight-color: transparent; } - :global(.dark) .key { - background: rgba(255, 255, 255, 0.08); - color: var(--color-gray-400); - } - .key:active { transform: scale(0.95); } .key.active { - background: var(--color-gray-900); - color: white; - } - - :global(.dark) .key.active { - background: white; - color: black; + background: var(--app-fg); + color: var(--app-bg); } diff --git a/cptr/frontend/src/lib/components/Sidebar.svelte b/cptr/frontend/src/lib/components/Sidebar.svelte index 31b1a58..b50bd73 100644 --- a/cptr/frontend/src/lib/components/Sidebar.svelte +++ b/cptr/frontend/src/lib/components/Sidebar.svelte @@ -151,7 +151,7 @@ .resize-handle:hover, .resize-handle.active { - background: rgba(150, 150, 150, 0.12); + background: var(--app-active); } } diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index 99022a7..c6f539a 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -411,7 +411,7 @@ transition: opacity 0.1s, transform 0.1s; - color: #9ca3af; + color: var(--app-fg-subtle); } :global(.dark) .ws-icon-chevron { @@ -458,12 +458,12 @@ width: 0.25rem; height: 0.25rem; border-radius: 50%; - background: #9ca3af; + background: var(--app-fg-subtle); animation: dotPulse 1s ease-in-out infinite; } :global(.dark) .ws-chat-loading-dot { - background: #6b7280; + background: var(--app-fg-muted); } .ws-chat-loading-dot:nth-child(2) { diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte index 2088355..95f78ba 100644 --- a/cptr/frontend/src/lib/components/Terminal.svelte +++ b/cptr/frontend/src/lib/components/Terminal.svelte @@ -155,25 +155,27 @@ resizeTimeout = setTimeout(() => doFit(), 350); } - function isDarkMode(): boolean { - return document.documentElement.classList.contains('dark'); + function themeColor(name: '--app-bg' | '--app-fg') { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } - const darkTheme = { - background: '#000000', - foreground: '#e0e0e0', - cursor: '#e0e0e0', - cursorAccent: '#000000', - selectionBackground: 'rgba(255, 255, 255, 0.2)' - }; - - const lightTheme = { - background: '#ffffff', - foreground: '#1a1a1a', - cursor: '#1a1a1a', - cursorAccent: '#ffffff', - selectionBackground: 'rgba(0, 0, 0, 0.15)' - }; + function withAlpha(color: string, alpha: number) { + const hex = /^#([0-9a-f]{6})$/i.exec(color)?.[1]; + if (!hex) return color; + return `rgba(${parseInt(hex.slice(0, 2), 16)}, ${parseInt(hex.slice(2, 4), 16)}, ${parseInt(hex.slice(4, 6), 16)}, ${alpha})`; + } + + function terminalTheme() { + const background = themeColor('--app-bg'); + const foreground = themeColor('--app-fg'); + return { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: withAlpha(foreground, 0.2) + }; + } // Send input to PTY via WebSocket (binary prefix protocol). // Writes directly into the pre-allocated buffer. Zero allocation @@ -256,16 +258,19 @@ scrollback: 10000, macOptionClickForceSelection: true, disableStdin: readOnly, - theme: isDarkMode() ? darkTheme : lightTheme + theme: terminalTheme() }); // Watch for theme changes const observer = new MutationObserver(() => { if (term) { - term.options.theme = isDarkMode() ? darkTheme : lightTheme; + term.options.theme = terminalTheme(); } }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style'] + }); // Handle macOS key combos that need special escape sequences term.attachCustomKeyEventHandler((e: KeyboardEvent) => { @@ -521,7 +526,7 @@ :global(.xterm-viewport) { overflow-y: auto !important; scrollbar-width: thin; - scrollbar-color: rgba(75, 85, 99, 0.4) transparent; + scrollbar-color: color-mix(in oklab, var(--app-fg) 40%, transparent) transparent; overscroll-behavior: contain; } /* When moved to flex sibling on mobile, override xterm's offscreen diff --git a/cptr/frontend/src/lib/components/automations/AutomationModal.svelte b/cptr/frontend/src/lib/components/automations/AutomationModal.svelte index a4dad1d..7e2e9ef 100644 --- a/cptr/frontend/src/lib/components/automations/AutomationModal.svelte +++ b/cptr/frontend/src/lib/components/automations/AutomationModal.svelte @@ -8,7 +8,8 @@ import DropdownMenu from '../DropdownMenu.svelte'; import ModelSelector from '../common/ModelSelector.svelte'; import ScheduleDropdown from './ScheduleDropdown.svelte'; - import { selectedModelId, workspaceList } from '$lib/stores'; + import { workspaceList } from '$lib/stores'; + import { defaultModel } from '$lib/stores/chat'; import { getPathDisplayName } from '$lib/utils/paths'; import type { AutomationData, AutomationForm } from '$lib/apis/automations'; import { createAutomation, updateAutomation } from '$lib/apis/automations'; @@ -25,7 +26,7 @@ let name = $state(automation?.name || ''); let prompt = $state(automation?.prompt || ''); - let modelId = $state(automation?.model_id || $selectedModelId || ''); + let modelId = $state(automation?.model_id || $defaultModel || ''); let workspace = $state(automation?.workspace || ''); let rrule = $state(automation?.rrule || 'RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0'); let saving = $state(false); diff --git a/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte b/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte index c2c530f..331e2ea 100644 --- a/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte +++ b/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte @@ -282,20 +282,12 @@ padding: 0.25rem 0.625rem; border-radius: 1rem; font-size: 0.75rem; - color: var(--color-gray-600); + color: var(--app-fg-muted); transition: background 0.1s; } - :global(.dark) .schedule-trigger { - color: var(--color-gray-400); - } - .schedule-trigger:hover { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .schedule-trigger:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--app-hover); } .schedule-panel { @@ -304,15 +296,10 @@ width: 12rem; padding: 0.25rem; border-radius: 1rem; - background: white; - border: 1px solid var(--color-gray-200); + background: var(--app-bg); + border: 1px solid var(--app-border); box-shadow: - 0 0.25rem 0.375rem -0.0625rem rgba(0, 0, 0, 0.1), - 0 0.125rem 0.25rem -0.125rem rgba(0, 0, 0, 0.1); - } - - :global(.dark) .schedule-panel { - background: #0a0a0a; - border-color: rgba(255, 255, 255, 0.08); + 0 0.25rem 0.375rem -0.0625rem color-mix(in oklab, var(--app-fg) 10%, transparent), + 0 0.125rem 0.25rem -0.125rem color-mix(in oklab, var(--app-fg) 10%, transparent); } diff --git a/cptr/frontend/src/lib/components/chat/AskUserCard.svelte b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte new file mode 100644 index 0000000..1976250 --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte @@ -0,0 +1,218 @@ + + +
+
+
+ Planning question +
+ {#if pending} +
+ {questionIndex + 1} of {questions.length} ยท paused while visible +
+ {:else if item.timed_out || resolved.timed_out} +
Recommendations used
+ {/if} +
+ +
+ {#if question} + {#key question.id} +
+
+
+ {question.header} +
+
+ {question.question} +
+
+ {#if pending} +
+ {#each question.options || [] as option, optionIndex} + + {/each} + + {#if selections[question.id] === '__other__'} + + (otherAnswers = { ...otherAnswers, [question.id]: event.currentTarget.value })} + /> + {/if} +
+ {:else} +
+ {resolved.answers?.[question.id]?.answers?.join(', ') || 'No answer recorded'} +
+ {/if} +
+ {/key} + {/if} + +
+ + {#if questionIndex < questions.length - 1} + + {:else if pending} + + {/if} +
+
+
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index bc0de9c..0a8e991 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -158,6 +158,15 @@ collapsedFiles = { ...collapsedFiles, [key]: !collapsedFiles[key] }; } + function openArtifact(artifact: any) { + const workspace = get(currentWorkspace); + const path = + workspace && artifact.path + ? `${workspace.path.replace(/\/$/, '')}/${artifact.path}` + : artifact.path; + if (path) openFileTab(path); + } + /** Human-readable label for a tool call */ function toolLabel(name: string, args: any): string { const _t = $t; @@ -201,6 +210,10 @@ return _t('chat.tool.killTask', { id: args.task_id || '?' }); case 'image_generate': return args.image || args.images?.length ? 'Edit image' : 'Generate image'; + case 'ask_user': { + const count = Array.isArray(args.questions) ? args.questions.length : 0; + return `Asked ${count} question${count === 1 ? '' : 's'}`; + } case 'web_search': return _t('chat.tool.webSearch', { query: args.query || '?' }); case 'read_url': { @@ -343,9 +356,11 @@ for (const [index, item] of output.entries()) { if (item.type === 'function_call') { - ensureGroup(); - currentGroup!.entries.push(item); - currentGroup!.calls.push(item); + if (item.name !== 'ask_user' || item.status !== 'pending') { + ensureGroup(); + currentGroup!.entries.push(item); + currentGroup!.calls.push(item); + } } else if (item.type === 'reasoning') { ensureGroup(); currentGroup!.entries.push(item); @@ -458,13 +473,7 @@ hover:border-gray-300 dark:hover:border-white/12 hover:bg-gray-50/50 dark:hover:bg-white/[0.03] transition-colors duration-150 {preview ? 'h-[4.375rem]' : 'h-[2.375rem]'}" - onclick={() => { - const ws = get(currentWorkspace); - if (ws && artifact.path) { - const fullPath = ws.path.replace(/\/$/, '') + '/' + artifact.path; - openFileTab(fullPath); - } - }} + onclick={() => openArtifact(artifact)} >
diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 23c48f0..8206d2b 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -27,14 +27,14 @@ import DictateButton from './DictateButton.svelte'; import QueuedMessageItem from './QueuedMessageItem.svelte'; import Tasks from './Tasks.svelte'; + import AskUserCard from './AskUserCard.svelte'; import Icon from '../Icon.svelte'; - import { planMode } from '$lib/stores'; + import type { ToolApprovalMode } from '$lib/apis/chat'; import { sttConfigured, ttsConfigured, ttsEnabled, unlockTtsAudioPlayback, - voiceModeEnabled, voiceModeSttMode } from '$lib/stores/audio'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -65,12 +65,17 @@ interface Props { inputText: string; selectedModel: string; + toolApprovalMode?: ToolApprovalMode; + planMode?: boolean; + requestParams?: Record; + voiceModeEnabled?: boolean; sending: boolean; streaming?: boolean; workspace?: string; placeholder?: string; contextUsage?: ContextUsage | null; tasks?: ChatTask[]; + askUser?: any; queuedMessages?: { id: string; content: string }[]; hasChatContent?: boolean; onsend: () => void; @@ -80,19 +85,31 @@ onstatus?: () => void; onskillslist?: () => void; oncancel?: () => void; + onaskuseranswer?: ( + messageId: string, + callId: string, + answers: Record, + timedOut: boolean + ) => void; onqueuesendnow?: (id: string) => void; onqueueedit?: (id: string) => void; onqueuedelete?: (id: string) => void; + onsettingschange?: () => void; } let { inputText = $bindable(), selectedModel = $bindable(), + toolApprovalMode = $bindable('auto'), + planMode = $bindable(false), + requestParams = $bindable({}), + voiceModeEnabled = $bindable(false), sending, streaming = false, workspace = '', placeholder = 'Message...', contextUsage = null, tasks = [], + askUser = null, queuedMessages = [], hasChatContent = false, onsend, @@ -102,9 +119,11 @@ onstatus, onskillslist, oncancel, + onaskuseranswer, onqueuesendnow, onqueueedit, - onqueuedelete + onqueuedelete, + onsettingschange }: Props = $props(); let editorEl: HTMLDivElement | undefined = $state(); @@ -127,7 +146,7 @@ const voiceStatusLabel = $derived( voiceWaitingForResponse || streaming || sending ? $t('chat.voiceWaiting') - : voiceListening || voiceRearming || $voiceModeEnabled + : voiceListening || voiceRearming || voiceModeEnabled ? $t('chat.voiceListening') : $t('chat.voiceModeOn') ); @@ -160,7 +179,6 @@ } 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/'); @@ -669,7 +687,7 @@ clearVoiceRestartTimer(); if ( !voiceModeAvailable || - !$voiceModeEnabled || + !voiceModeEnabled || voiceWaitingForResponse || voiceListening || voiceRecognition || @@ -813,7 +831,7 @@ clearVoiceRestartTimer(false); if ( !voiceModeAvailable || - !$voiceModeEnabled || + !voiceModeEnabled || voiceWaitingForResponse || voiceListening || voiceRecognition || @@ -829,7 +847,7 @@ (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; if (!SpeechRecognition) { alert($t('chat.dictate.unsupported')); - voiceModeEnabled.set(false); + voiceModeEnabled = false; voiceRearming = false; return; } @@ -881,7 +899,7 @@ recognition.onerror = (event: any) => { voiceListening = false; if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') { - voiceModeEnabled.set(false); + voiceModeEnabled = false; stopVoiceRecognition(); } }; @@ -913,8 +931,9 @@ alert($t('chat.ttsNotConfigured')); return; } - const next = !$voiceModeEnabled; - voiceModeEnabled.set(next); + const next = !voiceModeEnabled; + voiceModeEnabled = next; + onsettingschange?.(); if (next) { void unlockTtsAudioPlayback(); void acquireVoiceWakeLock(); @@ -930,11 +949,11 @@ } $effect(() => { - if (!voiceModeAvailable && $voiceModeEnabled) { - voiceModeEnabled.set(false); + if (!voiceModeAvailable && voiceModeEnabled) { + voiceModeEnabled = false; stopVoiceRecognition(); } - if (!$voiceModeEnabled) return; + if (!voiceModeEnabled) return; if (streaming) voiceSawStreaming = true; if (voiceSawStreaming && !streaming && !sending) { voiceWaitingForResponse = false; @@ -944,7 +963,7 @@ }); $effect(() => { - if ($voiceModeEnabled && inputText.trim()) { + if (voiceModeEnabled && inputText.trim()) { stopVoiceRecognition(); } }); @@ -1109,6 +1128,18 @@ }} role="presentation" > + {#if askUser && onaskuseranswer} +
+ +
+ {/if} + {#if tasks.length > 0}
@@ -1226,7 +1257,7 @@ {$t('chat.commandPlan')} - {$planMode ? $t('chat.commandPlanOn') : $t('chat.commandPlanOff')} + {planMode ? $t('chat.commandPlanOn') : $t('chat.commandPlanOff')} @@ -1443,7 +1474,7 @@ {/if}
- {#if $voiceModeEnabled} + {#if voiceModeEnabled}
e.stopPropagation()} >
- {#if workspace} - { - if (files) processFiles(Array.from(files)); - }} - oncapture={(file) => { - processFiles([file]); - }} - /> - {/if} - {#if $planMode} + { + if (files) processFiles(Array.from(files)); + }} + oncapture={(file) => { + processFiles([file]); + }} + /> + {#if planMode}
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index c2958b4..ca79de6 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -7,6 +7,7 @@ forkChat as apiForkChat, sendMessage as apiSendMessage, approveToolCall, + answerAskUser, cancelTask, compactChat as apiCompactChat, updateCurrentMessage, @@ -14,9 +15,11 @@ createMessage, queueSendNow as apiQueueSendNow, queueDelete as apiQueueDelete, + updateChatSettings, type ChatMessageRow, type ChatSendParams, type ChatInfo, + type ToolApprovalMode, type ContextUsage, type ChatTask } from '$lib/apis/chat'; @@ -30,16 +33,7 @@ import { socketStore } from '$lib/stores/socket.svelte'; import { onMount, onDestroy, tick } from 'svelte'; import { get } from 'svelte/store'; - import { - currentWorkspace, - openChatTab, - toolApprovalMode, - planMode, - streamingBehavior, - selectedModelId, - requestParams, - widescreenMode - } from '$lib/stores'; + import { currentWorkspace, openChatTab, streamingBehavior, widescreenMode } from '$lib/stores'; import { getPathDisplayName } from '$lib/utils/paths'; import { ttsEnabled, @@ -49,8 +43,7 @@ ttsAutoStreamEnabled, ttsPlaybackEnabled, ttsVoice, - unlockTtsAudioPlayback, - voiceModeEnabled + unlockTtsAudioPlayback } from '$lib/stores/audio'; import ChatInput from './ChatInput.svelte'; @@ -76,15 +69,49 @@ workspace?: string; chatId?: string; tabId?: string; + active?: boolean; ontabupdate?: (tabId: string, chatId: string, label: string) => void; onopenchat?: (chatId?: string) => void; } - let { workspace = '', chatId: initialChatId, tabId, ontabupdate, onopenchat }: Props = $props(); + let { + workspace = '', + chatId: initialChatId, + tabId, + active = true, + ontabupdate, + onopenchat + }: Props = $props(); let inputText = $state(''); let chatId = $state(initialChatId ?? null); let selectedModel = $state(''); + let toolApprovalMode = $state('auto'); + let planMode = $state(false); + let requestParams = $state>({}); + let voiceModeEnabled = $state(false); let allMessages = $state([]); + const pendingAskUser = $derived.by(() => { + for (const message of [...allMessages].reverse()) { + const item = message.output?.find( + (output: any) => + output.type === 'function_call' && + output.name === 'ask_user' && + output.status === 'pending' + ); + if (item) { + return { + item, + output: message.output?.find( + (output: any) => + output.type === 'function_call_output' && output.call_id === item.call_id + ), + chatId, + messageId: message.id + }; + } + } + return null; + }); let currentMessageId = $state(null); let contextUsage = $state(null); let chatTasks = $state([]); @@ -370,10 +397,11 @@ const savedScroll = !isInitialLoad && messagesEl ? messagesEl.scrollTop : -1; try { - const data = await getChat(id, selectedModel || undefined); + const data = await getChat(id); // Discard stale response if a newer loadChat was called while we waited if (gen !== loadGeneration) return; allMessages = data.messages; + loadChatSettings(data.chat.meta); currentMessageId = data.chat.current_message_id; contextUsage = data.context_usage ?? null; setChatTasks( @@ -619,13 +647,49 @@ if (chatId) loadChat(chatId); } - onMount(() => { + function resetChatSettings() { const models = get(chatModels); - const saved = get(selectedModelId); const dm = get(defaultModel); - if (saved && models.some((m) => m.id === saved)) selectedModel = saved; - else if (dm) selectedModel = dm; + if (dm) selectedModel = dm; else if (models.length) selectedModel = models[0].id; + toolApprovalMode = 'auto'; + planMode = false; + requestParams = {}; + voiceModeEnabled = false; + } + + function loadChatSettings(meta: Record | null) { + resetChatSettings(); + const params = meta?.params; + if (!params || typeof params !== 'object') return; + if ( + params.tool_approval_mode === 'ask' || + params.tool_approval_mode === 'auto' || + params.tool_approval_mode === 'full' + ) { + toolApprovalMode = params.tool_approval_mode; + } + planMode = params.plan_mode === true; + if (params.request_params && typeof params.request_params === 'object') { + requestParams = params.request_params; + } + voiceModeEnabled = params.voice_mode === true; + const models = get(chatModels); + if ( + typeof meta?.last_model === 'string' && + models.some((model) => model.id === meta.last_model) + ) { + selectedModel = meta.last_model; + } + } + + async function persistChatSettings() { + if (!chatId || !selectedModel) return; + await updateChatSettings(chatId, selectedModel, getChatSendParams()).catch(() => {}); + } + + onMount(() => { + resetChatSettings(); if (chatId) { loadChat(chatId); @@ -660,23 +724,17 @@ $effect(() => { if (!$ttsEnabled || !$ttsConfigured) { - voiceModeEnabled.set(false); + voiceModeEnabled = false; ttsPlaybackEnabled.set(false); stopTtsPlayback(); } else if ( - (!$ttsPlaybackEnabled && !$voiceModeEnabled && !$ttsAutoStreamEnabled) || + (!$ttsPlaybackEnabled && !voiceModeEnabled && !$ttsAutoStreamEnabled) || !$ttsConfigured ) { stopTtsPlayback(); } }); - // โ”€โ”€ Persist model selection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - $effect(() => { - if (selectedModel) selectedModelId.set(selectedModel); - }); - // โ”€โ”€ Sync streaming state to shared store for tab icon โ”€โ”€โ”€โ”€ $effect(() => { @@ -698,6 +756,22 @@ return () => unregisterStreamingChat(chatId!); }); + $effect(() => { + if (!chatId || !tabId) return; + const send = () => + socketStore + .getSocket() + ?.emit('chat:view', { chat_id: chatId, view_id: tabId, visible: active }); + send(); + const off = socketStore.on('connect', send); + return () => { + off(); + socketStore + .getSocket() + ?.emit('chat:view', { chat_id: chatId, view_id: tabId, visible: false }); + }; + }); + // โ”€โ”€ Auto-scroll โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function scrollToBottom() { @@ -740,11 +814,11 @@ function getChatSendParams(): ChatSendParams { const params: ChatSendParams = { - tool_approval_mode: get(toolApprovalMode), - plan_mode: get(planMode), - request_params: get(requestParams) + tool_approval_mode: toolApprovalMode, + plan_mode: planMode, + request_params: requestParams }; - if (get(voiceModeEnabled)) params.voice_mode = true; + if (voiceModeEnabled) params.voice_mode = true; return params; } @@ -957,7 +1031,8 @@ } function handlePlanCommand() { - planMode.set(!get(planMode)); + planMode = !planMode; + persistChatSettings(); } function firstUserMessageTitle(): string { @@ -1115,6 +1190,19 @@ }); } + function handleAskUserAnswer( + messageId: string, + callId: string, + answers: Record, + timedOut: boolean + ) { + if (!chatId) return; + answerAskUser(chatId, messageId, callId, answers, timedOut).catch((err) => { + console.error('[chat] ask_user answer error', err); + loadChat(chatId!); + }); + } + function handleNavigate(messageId: string, direction: -1 | 1) { const entry = activePath.find((p) => p.msg.id === messageId); if (!entry) return; @@ -1228,12 +1316,12 @@ return ( $ttsEnabled && $ttsConfigured && - ($ttsPlaybackEnabled || $voiceModeEnabled || $ttsAutoStreamEnabled) + ($ttsPlaybackEnabled || voiceModeEnabled || $ttsAutoStreamEnabled) ); } function shouldStreamTts() { - return $ttsEnabled && $ttsConfigured && ($voiceModeEnabled || $ttsAutoStreamEnabled); + return $ttsEnabled && $ttsConfigured && (voiceModeEnabled || $ttsAutoStreamEnabled); } function resetTtsBuffer() { @@ -1423,7 +1511,7 @@ toast.error($t('admin.audio.tts') + ': ' + detail); } ttsPlaybackEnabled.set(false); - voiceModeEnabled.set(false); + voiceModeEnabled = false; } function scheduleTtsPrepare(generation: number) { @@ -1526,7 +1614,7 @@ if (generation === ttsGeneration) ttsPlaying = false; if (generation === ttsGeneration) speakingMessageId = null; if (generation === ttsGeneration) ttsStopRequested = false; - if (generation === ttsGeneration && !$voiceModeEnabled) ttsPlaybackEnabled.set(false); + if (generation === ttsGeneration && !voiceModeEnabled) ttsPlaybackEnabled.set(false); } } @@ -1577,12 +1665,19 @@ bind:this={chatInputEl} bind:inputText bind:selectedModel + bind:toolApprovalMode + bind:planMode + bind:requestParams + bind:voiceModeEnabled {sending} {workspace} placeholder={$t('chat.placeholder', { name: workspaceDisplayName })} tasks={chatTasks} + askUser={pendingAskUser} + onaskuseranswer={handleAskUserAnswer} onsend={send} onplan={handlePlanCommand} + onsettingschange={persistChatSettings} {queuedMessages} onqueuesendnow={handleQueueSendNow} onqueueedit={handleQueueEdit} @@ -1695,16 +1790,23 @@ bind:this={chatInputEl} bind:inputText bind:selectedModel + bind:toolApprovalMode + bind:planMode + bind:requestParams + bind:voiceModeEnabled {sending} {streaming} {workspace} {contextUsage} {hasChatContent} tasks={chatTasks} + askUser={pendingAskUser} + onaskuseranswer={handleAskUserAnswer} onsend={send} oncompact={handleManualCompact} onfork={handleForkChat} onplan={handlePlanCommand} + onsettingschange={persistChatSettings} onstatus={handleStatusCommand} onskillslist={handleSkillsListCommand} oncancel={handleCancel} diff --git a/cptr/frontend/src/lib/components/chat/PlusMenu.svelte b/cptr/frontend/src/lib/components/chat/PlusMenu.svelte index 202eab7..7073205 100644 --- a/cptr/frontend/src/lib/components/chat/PlusMenu.svelte +++ b/cptr/frontend/src/lib/components/chat/PlusMenu.svelte @@ -1,6 +1,6 @@