diff --git a/CHANGELOG.md b/CHANGELOG.md index 601c818..55e9735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 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.8.1] - 2026-07-06 + +### Added + +- ✅ **Visible task progress.** Chats can now show a live checklist so long-running work is easier to follow. +- 🕒 **Message timestamps.** Hover over message controls to see when a message was sent. + +### Changed + +- 🧰 **Better model tool control.** Admins can choose which built-in tool groups each model can use, from files and terminal access to web, memory, images, and sub-agents. +- 🚀 **More flexible sub-agents.** Sub-agent limits now support higher defaults and unlimited mode for teams that want more parallel work. + +### Fixed + +- 💬 **More reliable chat lists.** Chat history stays in the right order more consistently, even when a saved chat needs extra cleanup. +- ✨ **Cleaner skill suggestions.** Skill matches in chat now use a simpler, more consistent icon. + ## [0.8.0] - 2026-07-06 ### Added diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index 7aa2627..e54d32a 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -170,6 +170,7 @@ export interface ModelConfigEntry { request_params?: Record; system_prompt?: string; compact_token_threshold?: number; + builtin_tools?: Record | null; }; } diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index e9deb29..9277ba1 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -36,9 +36,18 @@ export interface ContextUsage { source: 'estimated'; } +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface ChatTask { + id: string; + content: string; + status: TaskStatus; +} + export interface ChatDetail { chat: ChatInfo; messages: ChatMessageRow[]; + tasks?: ChatTask[]; context_usage?: ContextUsage | null; } diff --git a/cptr/frontend/src/lib/components/Admin/Models.svelte b/cptr/frontend/src/lib/components/Admin/Models.svelte index c052fe9..1b34ea7 100644 --- a/cptr/frontend/src/lib/components/Admin/Models.svelte +++ b/cptr/frontend/src/lib/components/Admin/Models.svelte @@ -15,8 +15,10 @@ import Icon from '../Icon.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import ModelSelector from '$lib/components/common/ModelSelector.svelte'; + import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte'; type ParamRow = { key: string; value: string }; + type BuiltinToolsConfig = Record; type ModelEntry = { id: string; name: string; @@ -25,6 +27,7 @@ rows: ParamRow[]; systemPrompt: string; compactTokenThreshold: number | null; + builtinTools: BuiltinToolsConfig; dirty: boolean; }; @@ -37,6 +40,7 @@ let globalRows = $state([]); let globalSystemPrompt = $state(''); let globalCompactTokenThreshold = $state(null); + let globalBuiltinTools = $state({}); let globalDirty = $state(false); let globalExpanded = $state(false); let showVariables = $state(false); @@ -66,6 +70,41 @@ { name: 'MODEL', desc: 'Model ID being used' } ]; + const BUILTIN_TOOL_GROUPS = [ + { id: 'files', label: 'models.builtinTools.files', desc: 'models.builtinTools.filesDesc' }, + { + id: 'terminal', + label: 'models.builtinTools.terminal', + desc: 'models.builtinTools.terminalDesc' + }, + { id: 'web', label: 'models.builtinTools.web', desc: 'models.builtinTools.webDesc' }, + { + id: 'browser', + label: 'models.builtinTools.browser', + desc: 'models.builtinTools.browserDesc' + }, + { id: 'memory', label: 'models.builtinTools.memory', desc: 'models.builtinTools.memoryDesc' }, + { id: 'chats', label: 'models.builtinTools.chats', desc: 'models.builtinTools.chatsDesc' }, + { id: 'skills', label: 'models.builtinTools.skills', desc: 'models.builtinTools.skillsDesc' }, + { id: 'tasks', label: 'models.builtinTools.tasks', desc: 'models.builtinTools.tasksDesc' }, + { + id: 'automations', + label: 'models.builtinTools.automations', + desc: 'models.builtinTools.automationsDesc' + }, + { id: 'images', label: 'models.builtinTools.images', desc: 'models.builtinTools.imagesDesc' }, + { + id: 'subagents', + label: 'models.builtinTools.subagents', + desc: 'models.builtinTools.subagentsDesc' + }, + { + id: 'notifications', + label: 'models.builtinTools.notifications', + desc: 'models.builtinTools.notificationsDesc' + } + ]; + const DEFAULT_PROMPT_PLACEHOLDER = `You are Computer, a helpful assistant running inside the user's computer interface. You have access to tools to read, search, and modify files in the workspace, run commands, and use configured tools. Use them to help the user directly. Approach hard requests with initiative and persistence: make the best possible attempt, adapt as needed, and keep going unless a real constraint prevents progress. {{CPTR_CONTEXT}} @@ -95,6 +134,60 @@ Files: return Number.isFinite(threshold) && threshold > 0 ? Math.floor(threshold) : null; } + function parseBuiltinTools(config: ModelConfigEntry | undefined): BuiltinToolsConfig { + const value = config?.params?.builtin_tools; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter(([, enabled]) => typeof enabled === 'boolean') + ); + } + + function builtinEnabled( + tools: BuiltinToolsConfig, + group: string, + inherited: BuiltinToolsConfig | null = null + ): boolean { + if (tools[group] !== undefined) return tools[group] !== false; + if (inherited?.[group] !== undefined) return inherited[group] !== false; + return true; + } + + function setBuiltinEnabled( + tools: BuiltinToolsConfig, + group: string, + enabled: boolean, + inherited: BuiltinToolsConfig | null = null + ): BuiltinToolsConfig { + const next = { ...tools }; + const inheritedEnabled = inherited?.[group] !== undefined ? inherited[group] !== false : true; + if (enabled === inheritedEnabled) { + delete next[group]; + } else { + next[group] = enabled; + } + return next; + } + + function serializeBuiltinTools( + tools: BuiltinToolsConfig, + inherited: BuiltinToolsConfig | null = null + ): BuiltinToolsConfig | null { + const result: BuiltinToolsConfig = {}; + for (const group of BUILTIN_TOOL_GROUPS) { + if (tools[group.id] === undefined) continue; + const inheritedEnabled = + inherited?.[group.id] !== undefined ? inherited[group.id] !== false : true; + if (tools[group.id] !== inheritedEnabled) { + result[group.id] = tools[group.id]; + } + } + return Object.keys(result).length ? result : null; + } + + function configuredRowCount(rows: ParamRow[]): number { + return rows.filter((row) => row.key.trim()).length; + } + function rowsToRequestParams(rows: ParamRow[]): Record { const result: Record = {}; for (const { key, value } of rows) { @@ -119,8 +212,12 @@ Files: globalRows = parseRows(config['*']); globalSystemPrompt = config['*']?.params?.system_prompt || ''; globalCompactTokenThreshold = parseCompactTokenThreshold(config['*']); + globalBuiltinTools = parseBuiltinTools(config['*']); globalExpanded = - globalRows.length > 0 || !!globalSystemPrompt || globalCompactTokenThreshold !== null; + globalRows.length > 0 || + !!globalSystemPrompt || + globalCompactTokenThreshold !== null || + Object.keys(globalBuiltinTools).length > 0; } models = data.models.map((m) => { @@ -136,6 +233,7 @@ Files: rows: parseRows(mc), systemPrompt: (mc?.params as any)?.system_prompt || '', compactTokenThreshold: parseCompactTokenThreshold(mc), + builtinTools: parseBuiltinTools(mc), dirty: false }; }); @@ -204,13 +302,17 @@ Files: function buildParams( rows: ParamRow[], systemPrompt: string, - compactThreshold: number | null + compactThreshold: number | null, + builtinTools: BuiltinToolsConfig, + inheritedBuiltinTools: BuiltinToolsConfig | null = null ): Record { const params: Record = {}; const rp = rowsToRequestParams(rows); + const bt = serializeBuiltinTools(builtinTools, inheritedBuiltinTools); if (Object.keys(rp).length) params.request_params = rp; if (systemPrompt.trim()) params.system_prompt = systemPrompt.trim(); if (compactThreshold && compactThreshold > 0) params.compact_token_threshold = compactThreshold; + if (bt) params.builtin_tools = bt; return params; } @@ -221,7 +323,12 @@ Files: if (globalDirty) { promises.push( updateModelConfig('*', { - params: buildParams(globalRows, globalSystemPrompt, globalCompactTokenThreshold) + params: buildParams( + globalRows, + globalSystemPrompt, + globalCompactTokenThreshold, + globalBuiltinTools + ) }) ); } @@ -229,7 +336,13 @@ Files: if (model.dirty) { promises.push( updateModelConfig(model.id, { - params: buildParams(model.rows, model.systemPrompt, model.compactTokenThreshold) + params: buildParams( + model.rows, + model.systemPrompt, + model.compactTokenThreshold, + model.builtinTools, + globalBuiltinTools + ) }) ); } @@ -380,6 +493,47 @@ Files: {/snippet} +{#snippet builtinToolsField( + tools: BuiltinToolsConfig, + inherited: BuiltinToolsConfig | null, + onChange: (tools: BuiltinToolsConfig) => void, + resetLabel: string +)} +
+
+ {$t('models.builtinTools')} + {#if Object.keys(tools).length > 0} + + {/if} +
+
+ {#each BUILTIN_TOOL_GROUPS as group} +
+
+
+ {$t(group.label)} +
+
+ {$t(group.desc)} +
+
+ onChange(setBuiltinEnabled(tools, group.id, enabled, inherited))} + /> +
+ {/each} +
+
+{/snippet} +
{#if loading}
@@ -448,11 +602,11 @@ Files: {$t('models.defaults')} - {#if globalRows.filter((r) => r.key.trim()).length > 0 || globalSystemPrompt.trim()} + {#if configuredRowCount(globalRows) > 0 || globalSystemPrompt.trim()} {#if globalSystemPrompt.trim()}prompt{/if} - {#if globalRows.filter((r) => r.key.trim()).length > 0} - {globalRows.filter((r) => r.key.trim()).length} params + {#if configuredRowCount(globalRows) > 0} + {configuredRowCount(globalRows)} params {/if} {/if} @@ -480,6 +634,15 @@ Files: }, DEFAULT_PROMPT_PLACEHOLDER )} + {@render builtinToolsField( + globalBuiltinTools, + null, + (tools) => { + globalBuiltinTools = tools; + globalDirty = true; + }, + $t('models.resetToDefault') + )} {@render paramRows( globalRows, () => (globalDirty = true), @@ -546,6 +709,16 @@ Files: }, $t('models.systemPromptInherited') )} + {@render builtinToolsField( + model.builtinTools, + globalBuiltinTools, + (tools) => { + model.builtinTools = tools; + model.dirty = true; + models = [...models]; + }, + $t('models.resetToDefault') + )} {@render paramRows( model.rows, () => (model.dirty = true), diff --git a/cptr/frontend/src/lib/components/Admin/Subagents.svelte b/cptr/frontend/src/lib/components/Admin/Subagents.svelte index a8248fc..718e0f4 100644 --- a/cptr/frontend/src/lib/components/Admin/Subagents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Subagents.svelte @@ -11,8 +11,8 @@ let enabled = $state(false); let backgroundEnabled = $state(false); - let maxConcurrent = $state(3); - let maxAsync = $state(3); + let maxConcurrent = $state(20); + let maxAsync = $state(20); let maxIterations = $state(30); let maxOutput = $state(30000); let systemPrompt = $state(''); @@ -24,8 +24,8 @@ backgroundEnabled = config['subagents.background_enabled'] === true || config['subagents.background_enabled'] === 'true'; - maxConcurrent = Number(config['subagents.max_concurrent']) || 3; - maxAsync = Number(config['subagents.max_async']) || 3; + maxConcurrent = Number(config['subagents.max_concurrent']) || 20; + maxAsync = Number(config['subagents.max_async']) || 20; maxIterations = Number(config['subagents.max_iterations']) || 30; maxOutput = Number(config['subagents.max_output']) || 30000; systemPrompt = (config['subagents.system_prompt'] as string) || ''; @@ -86,8 +86,7 @@ id="sa-concurrent" type="number" bind:value={maxConcurrent} - min="1" - max="10" + min="-1" class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" /> + {:else if name === 'spark'} + {:else if name === 'plus'} {:else if name === 'xmark'} diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 448dca5..5eb73b1 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -5,6 +5,7 @@ import OutputEditView from './OutputEditView.svelte'; import ChatFilePreview from './ChatFilePreview.svelte'; import ConsecutiveActivityGroup from './ConsecutiveActivityGroup.svelte'; + import MessageTimestamp from './MessageTimestamp.svelte'; import ReasoningCollapsible from './ReasoningCollapsible.svelte'; import ToolCallCollapsible from './ToolCallCollapsible.svelte'; import { currentWorkspace, openFileTab } from '$lib/stores'; @@ -21,6 +22,7 @@ usage: Record | null; chatId: string | null; messageId: string; + createdAt?: number | null; siblingIndex?: number; siblingTotal?: number; speaking?: boolean; @@ -37,6 +39,7 @@ usage, chatId, messageId, + createdAt = null, siblingIndex = 0, siblingTotal = 1, speaking = false, @@ -501,7 +504,7 @@ {@const fileKey = filePath || file.path || `file-${displayItem.index}`} {@const collapsed = Boolean(collapsedFiles[fileKey])}
+
{#if siblingTotal > 1} {/if} +
{/if} {/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index c7efd5d..cc231e7 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -20,12 +20,13 @@ import { searchFiles } from '$lib/apis/files'; import { getSkills } from '$lib/apis/skills'; import { uploadFile } from '$lib/apis/files'; - import type { ContextUsage } from '$lib/apis/chat'; + import type { ChatTask, ContextUsage } from '$lib/apis/chat'; import ModelSelector from '../common/ModelSelector.svelte'; import SendButton from './SendButton.svelte'; import PlusMenu from './PlusMenu.svelte'; import DictateButton from './DictateButton.svelte'; import QueuedMessageItem from './QueuedMessageItem.svelte'; + import Tasks from './Tasks.svelte'; import Icon from '../Icon.svelte'; import { planMode } from '$lib/stores'; import { @@ -69,6 +70,7 @@ workspace?: string; placeholder?: string; contextUsage?: ContextUsage | null; + tasks?: ChatTask[]; queuedMessages?: { id: string; content: string }[]; onsend: () => void; oncompact?: () => void; @@ -88,6 +90,7 @@ workspace = '', placeholder = 'Message...', contextUsage = null, + tasks = [], queuedMessages = [], onsend, oncompact, @@ -1003,6 +1006,12 @@ }} role="presentation" > + {#if tasks.length > 0} +
+ +
+ {/if} + {#if queuedMessages.length > 0}
e.preventDefault()} onclick={() => { runSlashCommand('skills:list'); @@ -1191,14 +1201,13 @@ placement: 'right' }} class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75 - {slashCommandIds[selectedSlashCommandIndex] === 'skills:create' - ? 'app-interactive-active' - : ''}" + {slashCommandIds[selectedSlashCommandIndex] === 'skills:create' ? 'app-interactive-active' : ''}" onmousedown={(e) => e.preventDefault()} onclick={() => { runSlashCommand('skills:create'); }} - onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('skills:create'))} + onmouseenter={() => + (selectedSlashCommandIndex = slashCommandIds.indexOf('skills:create'))} > diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index dd2fb3c..439cffe 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -15,7 +15,8 @@ type ChatMessageRow, type ChatSendParams, type ChatInfo, - type ContextUsage + type ContextUsage, + type ChatTask } from '$lib/apis/chat'; import { chatModels, @@ -80,6 +81,7 @@ let allMessages = $state([]); let currentMessageId = $state(null); let contextUsage = $state(null); + let chatTasks = $state([]); let showStatusModal = $state(false); let showSkillsModal = $state(false); let skillsModalList = $state([]); @@ -107,6 +109,7 @@ let speakingMessageId = $state(null); let ttsStopRequested = false; let commandSessionsChatId: string | null = null; + let taskClearTimer: ReturnType | null = null; // This browser memory cache only helps while the current page is open, such as when // someone taps the same speak button twice. The backend cache is the durable source // for cross-session reuse and the workspace data flywheel. @@ -315,6 +318,23 @@ .map((m) => ({ id: m.id, content: m.content })) ); + function setChatTasks(tasks: ChatTask[]) { + if (taskClearTimer) { + clearTimeout(taskClearTimer); + taskClearTimer = null; + } + chatTasks = tasks; + if ( + tasks.length > 0 && + !tasks.some((task) => task.status === 'pending' || task.status === 'in_progress') + ) { + taskClearTimer = setTimeout(() => { + taskClearTimer = null; + chatTasks = []; + }, 4000); + } + } + $effect(() => { if (chatId === commandSessionsChatId) return; commandSessionsChatId = chatId; @@ -345,6 +365,13 @@ allMessages = data.messages; currentMessageId = data.chat.current_message_id; contextUsage = data.context_usage ?? null; + setChatTasks( + (data.tasks ?? []).some( + (task) => task.status === 'pending' || task.status === 'in_progress' + ) + ? (data.tasks ?? []) + : [] + ); // Update tab label with the real title from the DB if (tabId && data.chat.title) { chatTitle = data.chat.title; @@ -418,10 +445,12 @@ let landingRefreshTimer: ReturnType | null = null; function handleSocketEvent(data: { + type?: string; chat_id: string; - message_id: string; + message_id?: string; delta?: string; output?: any; + tasks?: ChatTask[]; done?: boolean; error?: string; pending_inputs_processed?: boolean; @@ -454,6 +483,11 @@ if (data.chat_id !== chatId) return; + if (data.type === 'chat:tasks') { + setChatTasks(data.tasks ?? []); + return; + } + // Title generated by backend — update tab label if (data.title && tabId) { updateTab(tabId, data.chat_id, data.title); @@ -465,6 +499,7 @@ return; } + if (!data.message_id) return; const msg = allMessages.find((m) => m.id === data.message_id); if (!msg) return; @@ -535,6 +570,9 @@ // the DB reload returns. This avoids a transient blank message if the // final `done` socket event beats the commit/read path. msg.done = true; + if (chatTasks.some((task) => task.status === 'pending' || task.status === 'in_progress')) { + setChatTasks([]); + } allMessages = [...allMessages]; loadChat(data.chat_id); } @@ -577,6 +615,7 @@ commandSessionsTimer = null; window.removeEventListener('cptr:inspect-command-session', handleInspectCommandSession); if (landingRefreshTimer) clearTimeout(landingRefreshTimer); + if (taskClearTimer) clearTimeout(taskClearTimer); // Don't clear streamingChatTabs here -- the global listener in // chat.ts handles cleanup when the "done" event arrives, so the // spinner persists even when the chat tab is not active. @@ -1481,6 +1520,7 @@ {sending} {workspace} placeholder={$t('chat.placeholder', { name: workspaceDisplayName })} + tasks={chatTasks} onsend={send} onstatus={handleStatusCommand} {queuedMessages} @@ -1524,6 +1564,7 @@ handleNavigate(msg.id, dir)} @@ -1537,6 +1578,7 @@ usage={msg.usage} {chatId} messageId={msg.id} + createdAt={msg.created_at} {siblingIndex} siblingTotal={siblingIds.length} speaking={speakingMessageId === msg.id} @@ -1590,6 +1632,7 @@ {streaming} {workspace} {contextUsage} + tasks={chatTasks} onsend={send} oncompact={handleManualCompact} onplan={handlePlanCommand} diff --git a/cptr/frontend/src/lib/components/chat/MessageTimestamp.svelte b/cptr/frontend/src/lib/components/chat/MessageTimestamp.svelte new file mode 100644 index 0000000..1d70ca4 --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/MessageTimestamp.svelte @@ -0,0 +1,48 @@ + + +{#if date && label} + +{/if} diff --git a/cptr/frontend/src/lib/components/chat/SkillSuggestionPopup.svelte b/cptr/frontend/src/lib/components/chat/SkillSuggestionPopup.svelte index bbf3981..8dfac47 100644 --- a/cptr/frontend/src/lib/components/chat/SkillSuggestionPopup.svelte +++ b/cptr/frontend/src/lib/components/chat/SkillSuggestionPopup.svelte @@ -1,4 +1,5 @@ + +{#if tasks.length > 0} +
+
+
+ + + {completedCount} + {$t('chat.tasksOutOf')} + {totalCount} + {$t('chat.tasksCompleted')} + +
+ +
+ + {#if !collapsed} +
+ {#each tasks as task, idx (task.id)} +
+ + {#if task.status === 'completed'} + + {:else if task.status === 'in_progress'} + + {:else if task.status === 'cancelled'} + + {:else} + + {/if} + + + {idx + 1}. {task.content} + +
+ {/each} +
+ {/if} +
+{/if} diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index af33d6f..ab4b84a 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -1,6 +1,7 @@