Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ 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.6.1] - 2026-06-20

### Added

- 🔍 **Firecrawl web search.** Added Firecrawl as a web search provider. Configure your API key from Settings > Web, or set `FIRECRAWL_API_KEY` as an environment variable. Firecrawl slots in between Brave and DuckDuckGo in the automatic provider order, and also works with a custom self-hosted endpoint.

### Changed

- 📂 **Workspace paths are normalized.** Paths like `~/Projects/myapp` and `/Users/you/Projects/myapp` now resolve to the same workspace instead of creating duplicates. Existing duplicates are cleaned up automatically when you open or save a workspace.
- 🔔 **Clicking a browser notification opens the chat.** Desktop notifications for completed tasks now take you straight to the conversation when you click them, instead of just bringing the window to the front.
- 🏷️ **Workspace names display consistently.** Folder names shown in the sidebar, search results, automation list, and notifications now all use the same logic, so you see the same label everywhere.
- 🧹 **Pending messages follow the right branch.** When queued messages or background sub-agent results come in, they now attach to the correct point in the conversation instead of occasionally landing on the wrong branch.

## [0.6.0] - 2026-06-20

### Added
Expand Down
77 changes: 55 additions & 22 deletions cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import DropdownMenu from '../DropdownMenu.svelte';
import ModelSelector from '../common/ModelSelector.svelte';
import { selectedModelId, workspaceList } from '$lib/stores';
import { getPathDisplayName } from '$lib/utils/paths';
import { createBot, updateBot, verifyBotToken, type BotData, type BotForm } from '$lib/apis/bots';
import { toast } from 'svelte-sonner';
import { t } from '$lib/i18n';
Expand Down Expand Up @@ -35,26 +36,30 @@

// Workspace dropdown
let showWsMenu = $state(false);
let wsBtnEl: HTMLButtonElement | undefined = $state();
let workspaceButtonEl: HTMLButtonElement | undefined = $state();

$effect(() => {
if (!workspace && $workspaceList.length > 0) {
workspace = $workspaceList[0].path;
}
});

let wsMenuItems = $derived(
$workspaceList.map((ws) => ({
label: ws.name,
let workspaceMenuItems = $derived(
$workspaceList.map((workspaceOption) => ({
label: workspaceOption.name,
icon: 'folder',
active: ws.path === workspace,
active: workspaceOption.path === workspace,
check: true,
onclick: () => { workspace = ws.path; }
onclick: () => {
workspace = workspaceOption.path;
}
}))
);

let selectedWsName = $derived(
$workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || $t('automationModal.selectWorkspace')
let selectedWorkspaceName = $derived(
$workspaceList.find((w) => w.path === workspace)?.name ||
getPathDisplayName(workspace) ||
$t('automationModal.selectWorkspace')
);

const platformHints: Record<string, string> = $derived({
Expand Down Expand Up @@ -89,7 +94,11 @@
name: name.trim(),
model_id: modelId,
workspace,
allowed_senders: allowedSenders.split(',').map((s) => s.trim()).filter(Boolean) || undefined
allowed_senders:
allowedSenders
.split(',')
.map((s) => s.trim())
.filter(Boolean) || undefined
};
if (token.trim()) update.token = token.trim();
await updateBot(bot.id, update);
Expand All @@ -100,7 +109,11 @@
token: token.trim(),
model_id: modelId,
workspace,
allowed_senders: allowedSenders.split(',').map((s) => s.trim()).filter(Boolean) || undefined
allowed_senders:
allowedSenders
.split(',')
.map((s) => s.trim())
.filter(Boolean) || undefined
});
}
onsave();
Expand Down Expand Up @@ -141,10 +154,14 @@
/>
</div>
<div class="w-28 shrink-0">
<label class="text-[10px] text-gray-400 dark:text-gray-600">{$t('messaging.platform')}</label>
<label class="text-[10px] text-gray-400 dark:text-gray-600"
>{$t('messaging.platform')}</label
>
<select
bind:value={platform}
onchange={() => { verifyResult = null; }}
onchange={() => {
verifyResult = null;
}}
class="block w-full bg-transparent text-[13px] text-gray-700 dark:text-gray-300 outline-none py-0.5 cursor-pointer"
>
<option value="telegram">Telegram</option>
Expand Down Expand Up @@ -177,7 +194,9 @@
<input
type="password"
bind:value={token}
placeholder={bot ? $t('messaging.tokenKeep') : platformHints[platform] || $t('messaging.tokenPaste')}
placeholder={bot
? $t('messaging.tokenKeep')
: platformHints[platform] || $t('messaging.tokenPaste')}
autocomplete="new-password"
class="flex-1 bg-transparent text-[13px] text-gray-700 dark:text-gray-300 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none py-0.5 font-mono"
/>
Expand All @@ -200,16 +219,22 @@
{#if verifyResult}
<p class="text-[10px] mb-1 text-gray-500 dark:text-gray-400">
{#if verifyResult.ok}
✓ {verifyResult.info?.username ? `@${verifyResult.info.username}` : `ID: ${verifyResult.info?.id}`}
✓ {verifyResult.info?.username
? `@${verifyResult.info.username}`
: `ID: ${verifyResult.info?.id}`}
{:else}
✗ {verifyResult.error}
{/if}
</p>
{/if}

<!-- Allowed senders -->
<label class="text-[10px] text-gray-400 dark:text-gray-600 mt-1">{$t('messaging.allowedSenders')}</label>
<p class="text-[10px] text-gray-300 dark:text-gray-700 mb-0.5">{$t('messaging.allowedSendersHint')}</p>
<label class="text-[10px] text-gray-400 dark:text-gray-600 mt-1"
>{$t('messaging.allowedSenders')}</label
>
<p class="text-[10px] text-gray-300 dark:text-gray-700 mb-0.5">
{$t('messaging.allowedSendersHint')}
</p>
<input
type="text"
bind:value={allowedSenders}
Expand All @@ -227,14 +252,22 @@

<!-- Workspace selector -->
<button
bind:this={wsBtnEl}
bind:this={workspaceButtonEl}
type="button"
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 transition-colors duration-100"
onclick={() => (showWsMenu = !showWsMenu)}
>
<Icon name="folder" size={12} />
<span class="truncate max-w-[120px]">{selectedWsName}</span>
<svg class="w-3 h-3 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<span class="truncate max-w-[120px]">{selectedWorkspaceName}</span>
<svg
class="w-3 h-3 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
Expand All @@ -256,10 +289,10 @@
</div>
</Modal>

{#if showWsMenu && wsBtnEl}
{#if showWsMenu && workspaceButtonEl}
<DropdownMenu
items={wsMenuItems}
anchor={wsBtnEl}
items={workspaceMenuItems}
anchor={workspaceButtonEl}
onclose={() => (showWsMenu = false)}
preferAbove={true}
maxHeight="15rem"
Expand Down
20 changes: 20 additions & 0 deletions cptr/frontend/src/lib/components/Admin/Web.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
let braveKey = $state('');
let perplexityKey = $state('');
let perplexityBaseUrl = $state('');
let firecrawlSearchKey = $state('');
let firecrawlSearchBaseUrl = $state('https://api.firecrawl.dev');
let ccKey = $state('');
let ccBaseUrl = $state('');
let ccModel = $state('');
Expand Down Expand Up @@ -46,6 +48,8 @@
braveKey = (config['web.brave_api_key'] as string) || '';
perplexityKey = (config['web.perplexity_api_key'] as string) || '';
perplexityBaseUrl = (config['web.perplexity_base_url'] as string) || '';
firecrawlSearchKey = (config['web.firecrawl_api_key'] as string) || '';
firecrawlSearchBaseUrl = (config['web.firecrawl_base_url'] as string) || 'https://api.firecrawl.dev';
ccKey = (config['web.chat_completions_api_key'] as string) || '';
ccBaseUrl = (config['web.chat_completions_base_url'] as string) || '';
ccModel = (config['web.chat_completions_model'] as string) || '';
Expand Down Expand Up @@ -77,6 +81,8 @@
'web.brave_api_key': braveKey,
'web.perplexity_api_key': perplexityKey,
'web.perplexity_base_url': perplexityBaseUrl,
'web.firecrawl_api_key': firecrawlSearchKey,
'web.firecrawl_base_url': firecrawlSearchBaseUrl,
'web.chat_completions_api_key': ccKey,
'web.chat_completions_base_url': ccBaseUrl,
'web.chat_completions_model': ccModel,
Expand Down Expand Up @@ -147,6 +153,7 @@
<option value="exa">Exa</option>
<option value="tavily">Tavily</option>
<option value="brave">Brave</option>
<option value="firecrawl">{$t('admin.browserFirecrawl')}</option>
<option value="perplexity">Perplexity</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="chat_completions">{$t('admin.webChatCompletions')}</option>
Expand Down Expand Up @@ -181,6 +188,19 @@
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webBraveHint')}</p>
</div>
{:else if searchProvider === 'firecrawl'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="web-fc-key">{$t('admin.webFirecrawlKey')}</label>
<input id="web-fc-key" type="password" bind:value={firecrawlSearchKey} placeholder="fc-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webFirecrawlHint')}</p>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="web-fc-url">{$t('admin.webFirecrawlBaseUrl')}</label>
<input id="web-fc-url" type="text" bind:value={firecrawlSearchBaseUrl} placeholder="https://api.firecrawl.dev"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.browserFirecrawlBaseUrlHint')}</p>
</div>
{:else if searchProvider === 'perplexity'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="pplx-key">{$t('admin.webPerplexityKey')}</label>
Expand Down
74 changes: 53 additions & 21 deletions cptr/frontend/src/lib/components/SearchModal.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { workspaceList, currentWorkspace, showSearch } from '$lib/stores';
import { getPathDisplayName } from '$lib/utils/paths';
import {
unifiedSearch,
getRecentChats,
Expand Down Expand Up @@ -34,9 +35,7 @@
// When on /automations or other non-workspace pages, $currentWorkspace
// retains the last workspace but we should search across all.
const urlWorkspacePath = $derived($page.url.searchParams.get('workspace'));
const effectiveWorkspace = $derived(
urlWorkspacePath ? $currentWorkspace : null
);
const effectiveWorkspace = $derived(urlWorkspacePath ? $currentWorkspace : null);

// Show recents when no query, search results when query exists
const showingRecents = $derived(!query.trim());
Expand Down Expand Up @@ -119,11 +118,10 @@
}
}

function workspaceName(wsPath: string): string {
const ws = $workspaceList.find((w) => w.path === wsPath);
if (ws) return ws.name;
const parts = wsPath.split('/');
return parts[parts.length - 1] || wsPath;
function getWorkspaceDisplayName(workspacePath: string): string {
const workspace = $workspaceList.find((item) => item.path === workspacePath);
if (workspace) return workspace.name;
return getPathDisplayName(workspacePath, workspacePath);
}

function selectChat(chat: ChatSearchResult) {
Expand All @@ -134,9 +132,13 @@
function selectFile(file: FileSearchResult) {
onclose();
if (file.type === 'file') {
goto(`/?workspace=${encodeURIComponent(file.workspace)}&file=${encodeURIComponent(file.path)}`);
goto(
`/?workspace=${encodeURIComponent(file.workspace)}&file=${encodeURIComponent(file.path)}`
);
} else if (file.type === 'directory') {
goto(`/?workspace=${encodeURIComponent(file.workspace)}&dir=${encodeURIComponent(file.path)}`);
goto(
`/?workspace=${encodeURIComponent(file.workspace)}&dir=${encodeURIComponent(file.path)}`
);
}
}

Expand Down Expand Up @@ -224,7 +226,11 @@
<div bind:this={resultsEl} class="overflow-y-auto px-1.5 pb-1.5 flex-1 min-h-0">
{#if showingRecents && filteredRecents.length > 0}
<!-- Recent chats (empty query) -->
<div class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5">{$t('search.recentChats')}</div>
<div
class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5"
>
{$t('search.recentChats')}
</div>
{#each filteredRecents as chat, i (chat.id)}
<button
data-idx={i}
Expand All @@ -236,18 +242,29 @@
onmouseenter={() => (selectedIndex = i)}
>
<span class="text-xs font-medium truncate flex-1">{chat.title}</span>
{#if i < 9 && !isWorkspaceScoped}
<KeyPill text={`⌘${i + 1}`} class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-100" />
{#if i < 9 && !isWorkspaceScoped}
<KeyPill
text={`⌘${i + 1}`}
class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-100"
/>
{/if}
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0">{workspaceName(chat.workspace)}</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0"
>{getWorkspaceDisplayName(chat.workspace)}</span
>
</button>
{/each}
{:else if showingRecents}
<div class="py-6 text-center text-xs text-gray-400 dark:text-gray-600">{$t('search.noRecentChats')}</div>
<div class="py-6 text-center text-xs text-gray-400 dark:text-gray-600">
{$t('search.noRecentChats')}
</div>
{:else if !showingRecents}
{#if displayedChats.length > 0}
<!-- Chat search results -->
<div class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5">{$t('search.chats')}</div>
<div
class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5"
>
{$t('search.chats')}
</div>
{#each displayedChats as chat, i (chat.id)}
{@const idx = i}
<button
Expand All @@ -262,20 +279,33 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium truncate">{chat.title}</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0">{relativeTime(chat.updated_at)}</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0"
>{relativeTime(chat.updated_at)}</span
>
</div>
{#if chat.snippet}
<div class="text-[11px] text-gray-400 dark:text-gray-600 truncate mt-0.5">{chat.snippet}</div>
<div class="text-[11px] text-gray-400 dark:text-gray-600 truncate mt-0.5">
{chat.snippet}
</div>
{/if}
</div>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0">{workspaceName(chat.workspace)}</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0"
>{getWorkspaceDisplayName(chat.workspace)}</span
>
</button>
{/each}
{/if}

{#if fileResults.length > 0}
<!-- File search results -->
<div class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5 {displayedChats.length > 0 ? 'mt-1' : ''}">{$t('search.files')}</div>
<div
class="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600 px-2 pt-1 pb-0.5 {displayedChats.length >
0
? 'mt-1'
: ''}"
>
{$t('search.files')}
</div>
{#each fileResults as file, i (file.path)}
{@const idx = displayedChats.length + i}
<button
Expand All @@ -296,7 +326,9 @@
<span class="text-[11px] text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap"
>{relPath(file.path, file.workspace)}</span
>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0 ml-auto">{workspaceName(file.workspace)}</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0 ml-auto"
>{getWorkspaceDisplayName(file.workspace)}</span
>
</button>
{/each}
{/if}
Expand Down
Loading
Loading