Skip to content
Merged

refac #133

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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ 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.6] - 2026-07-12

### Added

- ⏰ **Follow-up timers for chats.** Computer can now return to a chat at the right time to continue work, check again, or follow up.

### Changed

- ✨ **Clearer change reviews.** Changes in files are easier to spot, including larger edits.

## [0.9.5] - 2026-07-11

### Added
Expand Down
11 changes: 11 additions & 0 deletions cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ async def lifespan(app: FastAPI):

app.state.scheduler_task = asyncio.create_task(scheduler_worker_loop(app))

from cptr.utils.timers import recover_timers, timer_worker_loop

await recover_timers()
app.state.timer_task = asyncio.create_task(timer_worker_loop(app))

# Start messaging bots
from cptr.utils.bridge import BotManager

Expand All @@ -80,6 +85,12 @@ async def lifespan(app: FastAPI):
try:
yield
finally:
timer_task = getattr(app.state, "timer_task", None)
if timer_task:
timer_task.cancel()
with suppress(asyncio.CancelledError):
await timer_task

scheduler_task = getattr(app.state, "scheduler_task", None)
if scheduler_task:
scheduler_task.cancel()
Expand Down
6 changes: 3 additions & 3 deletions cptr/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def _env_int(name: str, default: int) -> int:
except ValueError:
return default


# ── Data directory ──────────────────────────────────────────
# Where cptr stores its database, config, and user data.
# Default: ~/.cptr
Expand All @@ -32,9 +33,7 @@ def _env_int(name: str, default: int) -> int:
LOG_FORMAT = os.environ.get("CPTR_LOG_FORMAT", "text").lower()

AUDIT_LOG_LEVEL = os.environ.get("CPTR_AUDIT_LOG_LEVEL", "NONE").upper()
AUDIT_LOG_PATH = Path(
os.environ.get("CPTR_AUDIT_LOG_PATH", str(DATA_DIR / "logs" / "audit.jsonl"))
)
AUDIT_LOG_PATH = Path(os.environ.get("CPTR_AUDIT_LOG_PATH", str(DATA_DIR / "logs" / "audit.jsonl")))
AUDIT_LOG_ROTATION = os.environ.get("CPTR_AUDIT_LOG_ROTATION", "10 MB")
AUDIT_MAX_BODY_SIZE = _env_int("CPTR_AUDIT_MAX_BODY_SIZE", 2048)
AUDIT_EXCLUDED_PATHS = [
Expand Down Expand Up @@ -82,6 +81,7 @@ def _env_int(name: str, default: int) -> int:

# ── Automation scheduler ────────────────────────────────────
AUTOMATION_POLL_INTERVAL = int(os.environ.get("AUTOMATION_POLL_INTERVAL", "10"))
TIMER_POLL_INTERVAL = int(os.environ.get("TIMER_POLL_INTERVAL", "1"))

# ── CORS ────────────────────────────────────────────────────
# Socket.IO CORS allowed origins.
Expand Down
21 changes: 20 additions & 1 deletion cptr/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ def label(self) -> str:


class EventDefinitions:
CHAT_READ = EventDefinition(
"chat.read",
"The user explicitly marked a chat as read.",
"Chat read",
)
CHAT_USER_MESSAGE = EventDefinition(
"chat.user_message",
"A user message was added to a chat.",
"User message",
)
CHAT_FINISHED = EventDefinition(
"chat.finished",
"A chat run finished successfully.",
Expand Down Expand Up @@ -191,7 +201,16 @@ async def handle_event(self, event: Event) -> None:
await dispatch_notification_event(event)


EVENT_SINKS = [NotificationEventSink()]
class TimerEventSink:
async def handle_event(self, event: Event) -> None:
if event.event not in {EVENTS.CHAT_READ.name, EVENTS.CHAT_USER_MESSAGE.name}:
return
from cptr.utils.timers import cancel_timers_for_event

await cancel_timers_for_event(event)


EVENT_SINKS = [TimerEventSink(), NotificationEventSink()]


async def publish_event(
Expand Down
2 changes: 2 additions & 0 deletions cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

interface Props {
content: string;
meta?: Record<string, any> | null;
done: boolean;
output: any[] | null;
usage: Record<string, number> | null;
Expand All @@ -35,6 +36,7 @@
}
let {
content,
meta = null,
done,
output,
usage,
Expand Down
5 changes: 4 additions & 1 deletion cptr/frontend/src/lib/components/chat/ChatInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
onqueueedit?: (id: string) => void;
onqueuedelete?: (id: string) => void;
onsettingschange?: () => void;
ontoolapprovalchange?: (mode: ToolApprovalMode) => void;
}
let {
inputText = $bindable(),
Expand Down Expand Up @@ -123,7 +124,8 @@
onqueuesendnow,
onqueueedit,
onqueuedelete,
onsettingschange
onsettingschange,
ontoolapprovalchange
}: Props = $props();

let editorEl: HTMLDivElement | undefined = $state();
Expand Down Expand Up @@ -1507,6 +1509,7 @@
bind:planMode
bind:requestParams
onchange={onsettingschange}
{ontoolapprovalchange}
onfiles={(files) => {
if (files) processFiles(Array.from(files));
}}
Expand Down
27 changes: 23 additions & 4 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@
import { socketStore } from '$lib/stores/socket.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { get } from 'svelte/store';
import { currentWorkspace, openChatTab, streamingBehavior, widescreenMode } from '$lib/stores';
import {
currentWorkspace,
openChatTab,
streamingBehavior,
toolApprovalMode as defaultToolApprovalMode,
widescreenMode
} from '$lib/stores';
import { getPathDisplayName } from '$lib/utils/paths';
import {
ttsEnabled,
Expand Down Expand Up @@ -663,7 +669,7 @@
const dm = get(defaultModel);
if (dm) selectedModel = dm;
else if (models.length) selectedModel = models[0].id;
toolApprovalMode = 'auto';
toolApprovalMode = get(defaultToolApprovalMode);
planMode = false;
requestParams = {};
voiceModeEnabled = false;
Expand Down Expand Up @@ -1062,6 +1068,12 @@
persistChatSettings();
}

function handleToolApprovalModeChange(mode: ToolApprovalMode) {
toolApprovalMode = mode;
defaultToolApprovalMode.set(mode);
persistChatSettings();
}

function firstUserMessageTitle(): string {
const message = allMessages.find((m) => m.role === 'user' && !isPendingHiddenMessage(m));
return message ? message.content.replace(/\s+/g, ' ').trim().slice(0, 80) : '';
Expand Down Expand Up @@ -1652,9 +1664,13 @@
>
{#if !isLanding}
<div
class="flex h-7 shrink-0 items-center gap-2 border-b border-gray-100 px-3 dark:border-white/6"
class="relative z-30 -mb-12 flex h-7 shrink-0 items-center gap-2 px-3 dark:border-white/6"
style="border-color: color-mix(in oklab, var(--app-fg) 8%, transparent);"
>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 -bottom-10 -z-10 bg-linear-to-b from-white via-40% via-white/90 to-97% to-transparent dark:from-gray-900 dark:via-gray-900/90 dark:to-transparent"
></div>
<div
class="min-w-0 flex-1 truncate text-[0.6875rem] font-medium text-gray-600 dark:text-gray-400"
>
Expand Down Expand Up @@ -1705,6 +1721,7 @@
onsend={send}
onplan={handlePlanCommand}
onsettingschange={persistChatSettings}
ontoolapprovalchange={handleToolApprovalModeChange}
{queuedMessages}
onqueuesendnow={handleQueueSendNow}
onqueueedit={handleQueueEdit}
Expand Down Expand Up @@ -1742,7 +1759,7 @@
<div
class="{$widescreenMode
? 'max-w-full'
: 'max-w-2xl'} mx-auto w-full px-4 pt-4 pb-16 flex flex-col gap-4"
: 'max-w-2xl'} mx-auto w-full px-4 pt-16 pb-16 flex flex-col gap-4"
>
{#if hasHiddenMessages}
<div bind:this={loadSentinelEl} class="h-1 w-full" aria-hidden="true"></div>
Expand All @@ -1761,6 +1778,7 @@
{:else}
<AssistantMessage
content={msg.content}
meta={msg.meta}
done={msg.done}
output={msg.output}
usage={msg.usage}
Expand Down Expand Up @@ -1834,6 +1852,7 @@
onfork={handleForkChat}
onplan={handlePlanCommand}
onsettingschange={persistChatSettings}
ontoolapprovalchange={handleToolApprovalModeChange}
onstatus={handleStatusCommand}
onskillslist={handleSkillsListCommand}
oncancel={handleCancel}
Expand Down
7 changes: 5 additions & 2 deletions cptr/frontend/src/lib/components/chat/PlusMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
planMode?: boolean;
requestParams?: Record<string, unknown>;
onchange?: () => void;
ontoolapprovalchange?: (mode: ToolApprovalMode) => void;
}
let {
onfiles,
oncapture,
toolApprovalMode = $bindable('auto'),
planMode = $bindable(false),
requestParams = $bindable({}),
onchange
onchange,
ontoolapprovalchange
}: Props = $props();

let open = $state(false);
Expand Down Expand Up @@ -108,7 +110,8 @@

function selectMode(mode: ToolApprovalMode) {
toolApprovalMode = mode;
onchange?.();
if (ontoolapprovalchange) ontoolapprovalchange(mode);
else onchange?.();
}

function triggerUpload() {
Expand Down
30 changes: 29 additions & 1 deletion cptr/frontend/src/lib/components/chat/UserMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
let copied = $state(false);
let textareaEl: HTMLTextAreaElement;
let asyncExpanded = $state(false);
let timerExpanded = $state(false);
const isAsyncSubagentResult = $derived(meta?.async_subagent_result === true);
const isTimer = $derived(meta?.internal === true && meta?.type === 'timer');
const delegationId = $derived(meta?.delegation_id || '');
const delegationIds = $derived(Array.isArray(meta?.delegation_ids) ? meta.delegation_ids : []);
const delegationLabel = $derived(
Expand Down Expand Up @@ -121,7 +123,33 @@
</script>

<div class="group">
{#if isAsyncSubagentResult}
{#if isTimer}
<div class="w-full min-w-0">
<button
type="button"
class="w-full min-w-0 flex items-center gap-2 text-left text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
aria-expanded={timerExpanded}
onclick={() => (timerExpanded = !timerExpanded)}
>
<span class="text-[0.75rem] font-medium shrink-0">Timer</span>
<span class="text-[0.75rem] truncate min-w-0 flex-1">{content}</span>
<Icon
name="chevron-down"
size={12}
class="text-gray-400 dark:text-gray-600 shrink-0 transition-transform duration-150 {timerExpanded
? 'rotate-180'
: ''}"
/>
</button>
{#if timerExpanded}
<div
class="mt-2 ml-3 border-l border-gray-100 dark:border-white/8 pl-3 text-[0.78125rem] leading-relaxed text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-words"
>
{content}
</div>
{/if}
</div>
{:else if isAsyncSubagentResult}
<div class="w-full min-w-0">
<button
type="button"
Expand Down
17 changes: 17 additions & 0 deletions cptr/frontend/src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,14 @@ export interface HomeState {
splitDirection: SplitDirection;
}

export type ToolApprovalMode = 'ask' | 'auto' | 'full';

export interface UserPreferences {
theme?: Theme;
appearance?: AppearancePreferences;
sidebarOpen: boolean;
sidebarWidth: number;
toolApprovalMode?: ToolApprovalMode;
locale: string;
workspaceOrder?: string[]; // ordered paths for sidebar drag-reorder
keybindings?: Record<string, string>; // user-customised keyboard shortcuts
Expand Down Expand Up @@ -298,6 +301,7 @@ if (typeof window !== 'undefined') {
}
export const sidebarWidth = writable(220);
export const theme = writable<Theme>('dark');
export const toolApprovalMode = writable<ToolApprovalMode>('auto');
export const appVersion = writable('');
export const lastSeenVersion = writable('');
export const latestVersion = writable('');
Expand Down Expand Up @@ -422,6 +426,7 @@ function persistPreferences(): void {
},
sidebarOpen: get(sidebarOpen),
sidebarWidth: get(sidebarWidth),
toolApprovalMode: get(toolApprovalMode),
locale: i18next.language,
workspaceOrder: get(workspaceOrder),
keybindings: get(keybindings),
Expand Down Expand Up @@ -459,6 +464,9 @@ function subscribeForPersistence() {
sidebarWidth.subscribe(() => {
if (get(stateLoaded)) persistPreferences();
});
toolApprovalMode.subscribe(() => {
if (get(stateLoaded)) persistPreferences();
});
workspaceOrder.subscribe(() => {
if (get(stateLoaded)) persistPreferences();
});
Expand Down Expand Up @@ -500,6 +508,15 @@ export async function loadPreferences(): Promise<void> {
themeConfig.set(sanitizeThemeConfig(appearance.themeConfig));
if (prefs.sidebarOpen !== undefined) sidebarOpen.set(prefs.sidebarOpen as boolean);
if (prefs.sidebarWidth !== undefined) sidebarWidth.set(prefs.sidebarWidth as number);
if (
prefs.toolApprovalMode === 'ask' ||
prefs.toolApprovalMode === 'auto' ||
prefs.toolApprovalMode === 'full'
) {
toolApprovalMode.set(prefs.toolApprovalMode);
} else if (prefs.autoApproveTools !== undefined) {
toolApprovalMode.set((prefs.autoApproveTools as boolean) ? 'full' : 'ask');
}
if (prefs.locale) changeLocale(prefs.locale as string);
if (Array.isArray(prefs.workspaceOrder)) workspaceOrder.set(prefs.workspaceOrder as string[]);
if (prefs.keybindings) loadKeybindings(prefs.keybindings as Record<string, string>);
Expand Down
Loading
Loading