From 7255649abf6a948205160971a9a8ea28b5141f8f Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:43:16 -0500
Subject: [PATCH 01/20] refac
---
cptr/frontend/src/lib/apis/admin.ts | 2 +-
.../src/lib/components/Admin/AgentProfileModal.svelte | 7 +++++--
cptr/frontend/src/lib/components/Admin/Agents.svelte | 6 ++++--
3 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts
index b2912a3..7aa2627 100644
--- a/cptr/frontend/src/lib/apis/admin.ts
+++ b/cptr/frontend/src/lib/apis/admin.ts
@@ -60,7 +60,7 @@ export const updateConfig = (config: Record) =>
// ── Agents ─────────────────────────────────────────────────
-export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode';
+export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode' | 'cline';
export type AgentMode = 'auto' | 'enabled' | 'disabled';
export type AgentStatus = 'ready' | 'not_found' | 'missing_dependency' | 'auth_unknown' | 'error';
diff --git a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte
index 57ffe39..93d1986 100644
--- a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte
+++ b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte
@@ -47,7 +47,8 @@
claude_code: 'claude',
cursor: 'agent',
grok: 'grok',
- opencode: 'opencode'
+ opencode: 'opencode',
+ cline: 'cline'
}[agent];
}
@@ -57,7 +58,8 @@
claude_code: 'Claude Code',
cursor: 'Cursor',
grok: 'Grok',
- opencode: 'OpenCode'
+ opencode: 'OpenCode',
+ cline: 'Cline'
}[agent];
}
@@ -126,6 +128,7 @@
+
diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte
index 5b20b12..a128992 100644
--- a/cptr/frontend/src/lib/components/Admin/Agents.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte
@@ -111,7 +111,8 @@
claude_code: 'claude',
cursor: 'agent',
grok: 'grok',
- opencode: 'opencode'
+ opencode: 'opencode',
+ cline: 'cline'
}[agent];
}
@@ -121,7 +122,8 @@
claude_code: 'Claude Code',
cursor: 'Cursor',
grok: 'Grok',
- opencode: 'OpenCode'
+ opencode: 'OpenCode',
+ cline: 'Cline'
}[agent];
}
From d3c9df5c5175ae0c2bb228bcc18123d5a63389a5 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:43:20 -0500
Subject: [PATCH 02/20] refac
---
cptr/utils/agents/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cptr/utils/agents/__init__.py b/cptr/utils/agents/__init__.py
index 60654d6..25c3b7d 100644
--- a/cptr/utils/agents/__init__.py
+++ b/cptr/utils/agents/__init__.py
@@ -1 +1 @@
-"""Coding agent support for Codex and Claude Code."""
+"""Coding agent support."""
From 6f7ee2d610ce9998d07b0aad7d7ddfa478986924 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:43:29 -0500
Subject: [PATCH 03/20] refac
---
cptr/utils/agents/acp.py | 21 +++++-
cptr/utils/agents/cline.py | 135 +++++++++++++++++++++++++++++++++++++
2 files changed, 153 insertions(+), 3 deletions(-)
create mode 100644 cptr/utils/agents/cline.py
diff --git a/cptr/utils/agents/acp.py b/cptr/utils/agents/acp.py
index 7870469..76a0754 100644
--- a/cptr/utils/agents/acp.py
+++ b/cptr/utils/agents/acp.py
@@ -17,7 +17,7 @@ def __init__(
args: list[str],
cwd: str,
env: dict[str, str],
- auth_method_id: str,
+ auth_method_id: str | None,
client_capabilities: dict[str, Any] | None = None,
resume_session_id: str | None = None,
auto_approve_permissions: bool = False,
@@ -37,6 +37,7 @@ def __init__(
self.events: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self.next_id = 1
self.session_id: str | None = None
+ self.initialize_result: dict[str, Any] = {}
self.setup_result: dict[str, Any] = {}
self.model_config_id: str | None = None
@@ -52,7 +53,7 @@ async def start(self) -> None:
)
self.reader_task = asyncio.create_task(self._reader_loop())
self.stderr_task = asyncio.create_task(self._stderr_loop())
- await self.request(
+ self.initialize_result = await self.request(
"initialize",
{
"protocolVersion": 1,
@@ -64,7 +65,9 @@ async def start(self) -> None:
"clientInfo": {"name": "cptr", "version": "0"},
},
)
- await self.request("authenticate", {"methodId": self.auth_method_id})
+ auth_method_id = self.auth_method_id or _first_auth_method(self.initialize_result)
+ if auth_method_id:
+ await self.request("authenticate", {"methodId": auth_method_id})
await self._open_session()
async def close(self) -> None:
@@ -270,6 +273,18 @@ def _extract_model_config_id(setup: dict[str, Any]) -> str | None:
return None
+def _first_auth_method(initialize_result: dict[str, Any]) -> str | None:
+ methods = initialize_result.get("authMethods")
+ if not isinstance(methods, list):
+ return None
+ for method in methods:
+ if isinstance(method, dict):
+ method_id = method.get("id")
+ if isinstance(method_id, str) and method_id.strip():
+ return method_id.strip()
+ return None
+
+
def _select_permission_option(params: dict[str, Any], kind: str) -> str | None:
options = params.get("options")
if not isinstance(options, list):
diff --git a/cptr/utils/agents/cline.py b/cptr/utils/agents/cline.py
new file mode 100644
index 0000000..ba385d7
--- /dev/null
+++ b/cptr/utils/agents/cline.py
@@ -0,0 +1,135 @@
+"""Cline ACP adapter."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from contextlib import suppress
+from typing import Any, AsyncIterator
+
+from cptr.utils.agents.acp import (
+ AcpClient,
+ acp_event_stream,
+ acp_text_from_update,
+ acp_tool_from_update,
+)
+from cptr.utils.agents.attachments import PreparedAgentAttachments
+from cptr.utils.agents.events import (
+ AgentDone,
+ AgentError,
+ AgentEvent,
+ AgentTextDelta,
+ AgentToolUpdate,
+)
+
+
+def _prompt_from_messages(messages: list[dict[str, Any]]) -> str:
+ parts: list[str] = []
+ for message in messages:
+ role = message.get("role", "user")
+ content = message.get("content", "")
+ if isinstance(content, list):
+ text = "\n".join(
+ str(block.get("text", "")) for block in content if isinstance(block, dict)
+ )
+ else:
+ text = str(content or "")
+ if text:
+ parts.append(f"[{role}]\n{text}")
+ return "\n\n".join(parts)
+
+
+def _auto_approve(chat_params: dict[str, Any]) -> bool:
+ if chat_params.get("tool_approval_mode") == "full":
+ return True
+ return bool(chat_params.get("auto_approve_tools"))
+
+
+async def run_cline_agent(
+ *,
+ profile: dict[str, Any],
+ model: str,
+ workspace: str,
+ messages: list[dict[str, Any]],
+ system_prompt: str,
+ chat_params: dict[str, Any],
+ resume_state: dict[str, Any] | None,
+ attachments: PreparedAgentAttachments,
+) -> AsyncIterator[AgentEvent]:
+ env = os.environ.copy()
+ if profile.get("home"):
+ env["HOME"] = os.path.expanduser(str(profile["home"]))
+
+ session_id = None
+ if resume_state and isinstance(resume_state.get("session_id"), str):
+ session_id = resume_state["session_id"]
+
+ client = AcpClient(
+ command=str(profile["command"]),
+ args=["--acp"],
+ cwd=workspace,
+ env=env,
+ auth_method_id=None,
+ resume_session_id=session_id,
+ auto_approve_permissions=_auto_approve(chat_params),
+ )
+ try:
+ await client.start()
+ if model != "default":
+ await client.set_model(model)
+
+ prompt = _prompt_from_messages(messages)
+ if system_prompt:
+ prompt = f"{system_prompt}\n\n{prompt}" if prompt else system_prompt
+
+ images = [
+ {"data": image.base64, "mimeType": image.mime_type} for image in attachments.images
+ ]
+ prompt_task = asyncio.create_task(client.prompt(prompt, images=images))
+ try:
+ async for event in acp_event_stream(client):
+ params = event.get("params") if isinstance(event.get("params"), dict) else {}
+ text = acp_text_from_update(params)
+ if text:
+ yield AgentTextDelta(text)
+ tool = acp_tool_from_update(params)
+ if tool:
+ yield AgentToolUpdate(**tool)
+ if prompt_task.done():
+ try:
+ next_event = await asyncio.wait_for(client.events.get(), timeout=0.25)
+ except asyncio.TimeoutError:
+ break
+ next_params = (
+ next_event.get("params")
+ if isinstance(next_event.get("params"), dict)
+ else {}
+ )
+ next_text = acp_text_from_update(next_params)
+ if next_text:
+ yield AgentTextDelta(next_text)
+ next_tool = acp_tool_from_update(next_params)
+ if next_tool:
+ yield AgentToolUpdate(**next_tool)
+ await prompt_task
+ finally:
+ if not prompt_task.done():
+ prompt_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await prompt_task
+
+ yield AgentDone(
+ resume_state={
+ "profile_id": profile["id"],
+ "session_id": client.session_id,
+ "workspace": workspace,
+ "model": model,
+ }
+ )
+ except asyncio.CancelledError:
+ await client.cancel()
+ raise
+ except Exception as exc: # noqa: BLE001 - surfaced in chat.
+ yield AgentError(str(exc))
+ finally:
+ await client.close()
From e687f0e4baa9f043d50a212f357087aa306b3fc5 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:43:37 -0500
Subject: [PATCH 04/20] refac
---
cptr/utils/agents/detection.py | 34 ++++++++++++++++++++++++++++++++++
cptr/utils/agents/models.py | 17 +++++++++++++++--
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py
index 364273a..672424d 100644
--- a/cptr/utils/agents/detection.py
+++ b/cptr/utils/agents/detection.py
@@ -158,6 +158,18 @@ async def detect_profile(profile: dict[str, Any]) -> AgentDetection:
)
return AgentDetection("ready", command, version, None, models)
+ if profile.get("agent") == "cline":
+ models = await _probe_cline_models(command, profile)
+ if not models:
+ return AgentDetection(
+ "auth_unknown",
+ command,
+ version,
+ "Could not discover Cline models. Check `cline auth` or add models manually.",
+ [],
+ )
+ return AgentDetection("ready", command, version, None, models)
+
return AgentDetection("error", command, version, "Unknown agent")
@@ -387,6 +399,28 @@ async def _probe_opencode_models(command: str, profile: dict[str, Any]) -> list[
await proc.wait()
+async def _probe_cline_models(command: str, profile: dict[str, Any]) -> list[str] | None:
+ from cptr.utils.agents.acp import AcpClient, acp_models_from_setup
+
+ env = os.environ.copy()
+ if profile.get("home"):
+ env["HOME"] = os.path.expanduser(str(profile["home"]))
+ client = AcpClient(
+ command=command,
+ args=["--acp"],
+ cwd=os.getcwd(),
+ env=env,
+ auth_method_id=None,
+ )
+ try:
+ await asyncio.wait_for(client.start(), timeout=10)
+ return acp_models_from_setup(client.setup_result) or None
+ except Exception:
+ return None
+ finally:
+ await client.close()
+
+
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
diff --git a/cptr/utils/agents/models.py b/cptr/utils/agents/models.py
index 4fba931..f2acf66 100644
--- a/cptr/utils/agents/models.py
+++ b/cptr/utils/agents/models.py
@@ -70,10 +70,20 @@
"server_url": "",
"server_password": "",
},
+ {
+ "id": "cline",
+ "agent": "cline",
+ "name": "Cline",
+ "mode": "auto",
+ "command": "cline",
+ "home": None,
+ "models": [],
+ "default_model": "",
+ },
]
_PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
-_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode"}
+_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode", "cline"}
_VALID_MODES = {"auto", "enabled", "disabled"}
_VALID_CODEX_APPROVAL = {"ask", "auto", "full"}
_VALID_CODEX_SANDBOX = {"read-only", "workspace-write", "danger-full-access"}
@@ -92,6 +102,7 @@
},
"grok": {"name": "Grok", "command": "grok", "model": ""},
"opencode": {"name": "OpenCode", "command": "opencode", "model": ""},
+ "cline": {"name": "Cline", "command": "cline", "model": ""},
}
@@ -107,7 +118,9 @@ def normalize_agent_profile(raw: dict[str, Any]) -> dict[str, Any]:
agent = str(profile.get("agent") or "").strip()
if agent not in _VALID_AGENTS:
- raise HTTPException(400, "agent must be codex, claude_code, cursor, grok, or opencode")
+ raise HTTPException(
+ 400, "agent must be codex, claude_code, cursor, grok, opencode, or cline"
+ )
defaults = _AGENT_DEFAULTS[agent]
mode = str(profile.get("mode") or "auto").strip()
From 7be9a0877981f01b09b1e89739c1e7eca3b8150f Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:44:00 -0500
Subject: [PATCH 05/20] refac
---
cptr/utils/chat_task.py | 2 ++
cptr/utils/model_targets.py | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 2226417..5e22161 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -1270,6 +1270,7 @@ async def save_session(agent_target: AgentModelTarget, resume_state: dict | None
async def _run_agent_target(agent_target: AgentModelTarget):
nonlocal content, text_buffer
from cptr.utils.agents.claude_code import run_claude_code_agent
+ from cptr.utils.agents.cline import run_cline_agent
from cptr.utils.agents.codex import run_codex_agent
from cptr.utils.agents.cursor import run_cursor_agent
from cptr.utils.agents.grok import run_grok_agent
@@ -1322,6 +1323,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
"cursor": run_cursor_agent,
"grok": run_grok_agent,
"opencode": run_opencode_agent,
+ "cline": run_cline_agent,
}
runner = runners.get(agent_target.agent)
if runner is None:
diff --git a/cptr/utils/model_targets.py b/cptr/utils/model_targets.py
index 41d7b23..fc01327 100644
--- a/cptr/utils/model_targets.py
+++ b/cptr/utils/model_targets.py
@@ -24,7 +24,7 @@ class ApiModelTarget:
class AgentModelTarget:
kind: Literal["agent"]
profile_id: str
- agent: Literal["codex", "claude_code", "cursor", "grok", "opencode"]
+ agent: Literal["codex", "claude_code", "cursor", "grok", "opencode", "cline"]
model: str
full_model_id: str
config: dict[str, Any]
From 4e73a201d1d8cd8177ba139cdf433d5b8ff82753 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:44:05 -0500
Subject: [PATCH 06/20] refac
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 11d27b0..d826508 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endp
Connect a coding agent as a native backend and use the subscription you already pay for. No separate API key needed.
-**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode**
+**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** · **Cline**
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.
From 89b97c76260a3957996543f771d0f9dc2553ad9d Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Sun, 5 Jul 2026 23:49:01 -0500
Subject: [PATCH 07/20] refac
---
cptr/utils/chat_task.py | 27 +++------------------------
1 file changed, 3 insertions(+), 24 deletions(-)
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 5e22161..372dc8e 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -1778,30 +1778,8 @@ async def _run_agent_target(agent_target: AgentModelTarget):
last_usage = usage
new_messages_since = 0
- if not pending_calls:
- # No tool calls — final response, we're done
- _flush_text()
- if streamed_reasoning_chars and not response_reasoning_items:
- logger.warning(
- "[task %s] reasoning output streamed (%d chars) but no completed reasoning item arrived before usage; DB output may contain only in-progress reasoning",
- message_id[:8],
- streamed_reasoning_chars,
- )
- await _save_message(
- "usage",
- content=content,
- output=output_items,
- usage=usage,
- done=True,
- )
- _task_state.pop(message_id, None)
- await _emit_done()
- return
-
elif event["type"] == "done":
- # Stream ended — if usage already triggered a save+return, we won't
- # reach here. This is the fallback for providers that don't support
- # stream_options.include_usage.
+ # Stream ended. Usage may have arrived earlier, multiple times, or never.
if not pending_calls:
_flush_text()
if streamed_reasoning_chars and not response_reasoning_items:
@@ -1811,7 +1789,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
streamed_reasoning_chars,
)
await _save_message(
- "done fallback",
+ "done",
content=content,
output=output_items,
usage=last_usage,
@@ -2030,6 +2008,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
"end",
content=content,
output=output_items,
+ usage=last_usage,
done=True,
)
_task_state.pop(message_id, None)
From 38235c75235030ac5b87597665bb30aee6ad77aa Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Mon, 6 Jul 2026 00:12:36 -0500
Subject: [PATCH 08/20] refac
---
cptr/frontend/src/app.css | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css
index 4b10260..00ae9ae 100644
--- a/cptr/frontend/src/app.css
+++ b/cptr/frontend/src/app.css
@@ -155,6 +155,11 @@
color: var(--app-fg);
}
+.app-theme :where(option) {
+ background: var(--app-bg);
+ color: var(--app-fg);
+}
+
.app-theme:where([class*='border-gray-'], [class*='border-black/'], [class*='border-white/']),
.app-theme :where([class*='border-gray-'], [class*='border-black/'], [class*='border-white/']) {
border-color: var(--app-border);
From 11f5292d4805078bdcda1b6cbe23c5dbcd241b93 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Mon, 6 Jul 2026 00:13:38 -0500
Subject: [PATCH 09/20] refac
---
.../src/lib/components/FileBrowser.svelte | 18 ++
.../src/lib/components/GroupTabBar.svelte | 46 ++++-
.../src/lib/components/chat/ChatInput.svelte | 13 ++
cptr/frontend/src/lib/constants.ts | 1 +
cptr/frontend/src/lib/stores.ts | 119 ++++++------
cptr/frontend/src/routes/+page.svelte | 169 ++++++++++++------
6 files changed, 246 insertions(+), 120 deletions(-)
create mode 100644 cptr/frontend/src/lib/constants.ts
diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte
index be0c803..d9c2a72 100644
--- a/cptr/frontend/src/lib/components/FileBrowser.svelte
+++ b/cptr/frontend/src/lib/components/FileBrowser.svelte
@@ -22,6 +22,7 @@
import Spinner from './common/Spinner.svelte';
import DropdownMenu from './DropdownMenu.svelte';
import { t } from '$lib/i18n';
+ import { TAB_DRAG_MIME } from '$lib/constants';
interface FileEntry {
name: string;
@@ -573,6 +574,12 @@
// ── Drag to move ────────────────────────────────────────────
+ function isTabDrag(e: DragEvent): boolean {
+ return Boolean(
+ e.dataTransfer?.types.includes(TAB_DRAG_MIME) || e.dataTransfer?.types.includes('text/tab-id')
+ );
+ }
+
/** Collect all paths being dragged: either selected items (if the dragged item is selected) or just the single item */
function getDraggedPaths(entry: TreeEntry): string[] {
if (selectedPaths.size > 0 && selectedPaths.has(entry.path)) {
@@ -605,6 +612,8 @@
}
function onDragOverDir(e: DragEvent, entry: TreeEntry) {
+ if (isTabDrag(e)) return;
+
// Determine the target folder for this entry
let targetDir: string | null;
if (entry.type === 'directory') {
@@ -665,6 +674,8 @@
}
async function onDropOnDir(e: DragEvent, entry: TreeEntry) {
+ if (isTabDrag(e)) return;
+
e.preventDefault();
if (dragExpandTimer) {
clearTimeout(dragExpandTimer);
@@ -744,6 +755,11 @@
// ── Drop zone for uploads ───────────────────────────────────
function onDropzoneOver(e: DragEvent) {
+ if (isTabDrag(e)) {
+ dropzoneActive = false;
+ return;
+ }
+
e.preventDefault();
if (draggedItem) {
// Internal drag — allow drop to move to current directory
@@ -762,6 +778,8 @@
}
async function onDropzoneDrop(e: DragEvent) {
+ if (isTabDrag(e)) return;
+
e.preventDefault();
dropzoneActive = false;
diff --git a/cptr/frontend/src/lib/components/GroupTabBar.svelte b/cptr/frontend/src/lib/components/GroupTabBar.svelte
index b76178d..165d417 100644
--- a/cptr/frontend/src/lib/components/GroupTabBar.svelte
+++ b/cptr/frontend/src/lib/components/GroupTabBar.svelte
@@ -29,6 +29,7 @@
import { tooltip } from '$lib/tooltip';
import { t } from '$lib/i18n';
import VoiceMemoModal from './VoiceMemoModal.svelte';
+ import { TAB_DRAG_MIME } from '$lib/constants';
interface Props {
group: EditorGroup;
@@ -57,6 +58,8 @@
// Drop target highlight for cross-group drag
let dropHighlight = $state(false);
+ type TabDragPayload = { tabId: string; groupId: string };
+
const displayTabs = $derived((group?.tabs ?? []).filter((t) => t.type !== 'git'));
const isActiveGroup = $derived($activeWorkspace?.activeGroupId === group?.id);
@@ -89,6 +92,11 @@
closeTab(tabId, group.id);
}
+ function handleCloseGroup(e: Event) {
+ e.stopPropagation();
+ closeGroup(group.id);
+ }
+
function handleContextMenu(e: MouseEvent, tab: Tab) {
if (!isWideScreen) return;
e.preventDefault();
@@ -108,11 +116,31 @@
// Native drag handlers are NOT needed. Sortable's setData handles it.
// We keep the native drop handlers on the bar for cross-group drops.
+ function hasTabDrag(dataTransfer: DataTransfer): boolean {
+ return dataTransfer.types.includes(TAB_DRAG_MIME) || dataTransfer.types.includes('text/tab-id');
+ }
+
+ function readTabDragPayload(dataTransfer: DataTransfer): TabDragPayload | null {
+ const raw = dataTransfer.getData(TAB_DRAG_MIME);
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed.tabId === 'string' && typeof parsed.groupId === 'string') {
+ return { tabId: parsed.tabId, groupId: parsed.groupId };
+ }
+ } catch {
+ // Fall through to the legacy payload below.
+ }
+ }
+
+ const tabId = dataTransfer.getData('text/tab-id');
+ const groupId = dataTransfer.getData('text/group-id');
+ return tabId && groupId ? { tabId, groupId } : null;
+ }
+
function handleBarDragOver(e: DragEvent) {
// Only accept tab drags (not file uploads)
- if (!e.dataTransfer?.types.includes('text/tab-id')) return;
- const sourceGroupId = e.dataTransfer.types.includes('text/group-id') ? 'unknown' : null;
- if (!sourceGroupId) return;
+ if (!e.dataTransfer || !hasTabDrag(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
dropHighlight = true;
@@ -125,12 +153,11 @@
function handleBarDrop(e: DragEvent) {
dropHighlight = false;
if (!e.dataTransfer) return;
- const tabId = e.dataTransfer.getData('text/tab-id');
- const fromGroupId = e.dataTransfer.getData('text/group-id');
- if (!tabId || !fromGroupId) return;
- if (fromGroupId === group.id) return; // same group, ignore
+ const payload = readTabDragPayload(e.dataTransfer);
+ if (!payload) return;
+ if (payload.groupId === group.id) return; // same group, ignore
e.preventDefault();
- moveTabToGroup(tabId, fromGroupId, group.id);
+ moveTabToGroup(payload.tabId, payload.groupId, group.id);
}
const plusMenuItems = $derived([
@@ -246,6 +273,7 @@
touchStartThreshold: 5,
setData: (dataTransfer: DataTransfer, dragEl: HTMLElement) => {
const tabId = dragEl.dataset.tabId ?? '';
+ dataTransfer.setData(TAB_DRAG_MIME, JSON.stringify({ tabId, groupId: group.id }));
dataTransfer.setData('text/tab-id', tabId);
dataTransfer.setData('text/group-id', group.id);
},
@@ -370,7 +398,7 @@
{#if canClose}
{:else}
-
+
{#each targets as target}
-
-
-
-
-
- {target.id}
-
-
- {target.type === 'webhook' ? $t('general.webhook') : $t('general.bot')}
-
-
-
- {target.type === 'webhook'
- ? target.config.url_masked
- : target.config.destination_chat_id}
-
+ {@const targetDestination =
+ target.type === 'webhook' ? target.config.url_masked : target.config.destination_chat_id}
+ {@const alertLabels = eventOptions
+ .filter((event) => target.events.includes(event.event))
+ .map((event) => event.label)
+ .join(', ')}
+
+
+
+
+ {target.id}
+
+
+ {target.type === 'webhook' ? $t('general.webhook') : $t('general.bot')}
+
+
+
+ {targetDestination}
+
+
+ {alertLabels || $t('general.noChatAlerts')}
+ {#if target.events.length}
+ · {target.delivery === 'away' ? $t('general.onlyWhenAway') : $t('general.always')}
+ {/if}
-
patchTarget(target, { enabled: v })}
- />
-
-
- {$t('general.automaticEvents')}
-
- {#if target.events.length}
-
- {#each eventOptions.filter((event) => target.events.includes(event.event)) as event}
-
- patchTarget(target, {
- events: target.events.filter((item) => item !== event.event)
- })}
- >
- {event.label}
-
- {/each}
-
- {:else}
-
- {$t('general.noChatAlerts')}
-
- {/if}
+
+ sendTest(target)}
+ >
+ {$t('general.sendTest')}
+
+ openEditTarget(target)}
+ >
+ {$t('common.edit')}
+
+ removeTarget(target)}
+ >
+ {$t('common.remove')}
+
-
- {#if target.events.length}
-
-
- {$t('general.automaticDelivery')}
-
-
- {#each ['away', 'always'] as mode}
-
- patchTarget(target, { delivery: mode as NotificationDelivery })}
- >
- {mode === 'away' ? $t('general.onlyWhenAway') : $t('general.always')}
-
- {/each}
-
-
- {:else}
-
- {/if}
-
- sendTest(target)}
- >
- {$t('general.sendTest')}
-
- openEditTarget(target)}
- >
- {$t('common.edit')}
-
- removeTarget(target)}
- >
- {$t('common.remove')}
-
-
+
+ patchTarget(target, { enabled: v })}
+ />
{/each}
@@ -471,3 +432,16 @@
{/if}
+
+
diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py
index 9f02c8a..3824789 100644
--- a/cptr/utils/notifications.py
+++ b/cptr/utils/notifications.py
@@ -4,6 +4,7 @@
import ipaddress
import logging
+import re
import socket
import time
from urllib.parse import urlparse
@@ -198,6 +199,23 @@ def _check_unique_id(targets: list[dict], new_id: str, old_id: str | None = None
async def create_target(user_id: str, payload: dict) -> dict:
data = await _state(user_id)
targets = _targets(data)
+ payload = dict(payload)
+ if not str(payload.get("id") or "").strip():
+ config = payload.get("config") or {}
+ target_type = str(payload.get("type") or "target").strip() or "target"
+ if target_type == "webhook":
+ base = urlparse(str(config.get("url") or "")).hostname or "webhook"
+ elif target_type == "bot":
+ base = str(config.get("bot_id") or "bot")
+ else:
+ base = target_type
+ base = re.sub(r"[^a-zA-Z0-9_-]+", "-", base).strip("-").lower() or "target"
+ candidate = base
+ suffix = 2
+ while any(str(target.get("id", "")).lower() == candidate.lower() for target in targets):
+ candidate = f"{base}-{suffix}"
+ suffix += 1
+ payload["id"] = candidate
target = _validate_target(payload)
if target["type"] == "bot":
await _ensure_user_bot(user_id, target["config"]["bot_id"])
From d403cd6bab0741998700be07426fd81721849c0d Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Mon, 6 Jul 2026 16:37:34 -0500
Subject: [PATCH 16/20] refac
---
cptr/frontend/src/lib/components/SettingsModal.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index ff0ac0a..ce7a0bd 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -127,7 +127,7 @@
{targetDestination}
@@ -274,6 +291,14 @@
>
{$t('general.sendTest')}
+ {#if !target.is_default}
+ makeDefault(target)}
+ >
+ {$t('general.makeDefault')}
+
+ {/if}
openEditTarget(target)}
diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json
index a62b383..16ae90c 100644
--- a/cptr/frontend/src/lib/i18n/locales/de.json
+++ b/cptr/frontend/src/lib/i18n/locales/de.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Nur wenn ich abwesend bin",
"general.always": "Immer",
"general.sendTest": "Test senden",
+ "general.makeDefault": "Als Standard festlegen",
"general.platformDestinationId": "Plattform-Ziel-ID",
"general.targetId": "Ziel-ID",
"general.targetIdForNotify": "Ziel-ID",
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json
index 3beb1fa..df0973d 100644
--- a/cptr/frontend/src/lib/i18n/locales/en.json
+++ b/cptr/frontend/src/lib/i18n/locales/en.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Only when I'm away",
"general.always": "Always",
"general.sendTest": "Send test",
+ "general.makeDefault": "Make default",
"general.platformDestinationId": "Platform destination ID",
"general.targetId": "Target ID",
"general.targetIdForNotify": "Target ID",
diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json
index d585d57..ff04415 100644
--- a/cptr/frontend/src/lib/i18n/locales/es.json
+++ b/cptr/frontend/src/lib/i18n/locales/es.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Solo cuando estoy ausente",
"general.always": "Siempre",
"general.sendTest": "Enviar prueba",
+ "general.makeDefault": "Hacer predeterminado",
"general.platformDestinationId": "ID de destino de la plataforma",
"general.targetId": "ID del destino",
"general.targetIdForNotify": "ID del destino",
diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json
index 67c7b74..9077855 100644
--- a/cptr/frontend/src/lib/i18n/locales/fr.json
+++ b/cptr/frontend/src/lib/i18n/locales/fr.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Seulement quand je suis absent",
"general.always": "Toujours",
"general.sendTest": "Envoyer un test",
+ "general.makeDefault": "Définir par défaut",
"general.platformDestinationId": "ID de destination de la plateforme",
"general.targetId": "ID de la cible",
"general.targetIdForNotify": "ID de la cible",
diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json
index ce1fe77..5f16125 100644
--- a/cptr/frontend/src/lib/i18n/locales/ja.json
+++ b/cptr/frontend/src/lib/i18n/locales/ja.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "離席中のみ",
"general.always": "常に",
"general.sendTest": "テスト送信",
+ "general.makeDefault": "既定に設定",
"general.platformDestinationId": "プラットフォーム送信先ID",
"general.targetId": "通知先ID",
"general.targetIdForNotify": "通知先ID",
diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json
index a92d137..fce5ade 100644
--- a/cptr/frontend/src/lib/i18n/locales/ko.json
+++ b/cptr/frontend/src/lib/i18n/locales/ko.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "자리 비움일 때만",
"general.always": "항상",
"general.sendTest": "테스트 보내기",
+ "general.makeDefault": "기본값으로 설정",
"general.platformDestinationId": "플랫폼 대상 ID",
"general.targetId": "대상 ID",
"general.targetIdForNotify": "대상 ID",
diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json
index 2cee645..1bcb213 100644
--- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json
+++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Só quando eu estiver ausente",
"general.always": "Sempre",
"general.sendTest": "Enviar teste",
+ "general.makeDefault": "Definir como padrão",
"general.platformDestinationId": "ID do destino na plataforma",
"general.targetId": "ID do destino",
"general.targetIdForNotify": "ID do destino",
diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json
index a37ef5b..5d0b7ea 100644
--- a/cptr/frontend/src/lib/i18n/locales/ru.json
+++ b/cptr/frontend/src/lib/i18n/locales/ru.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "Только когда меня нет",
"general.always": "Всегда",
"general.sendTest": "Отправить тест",
+ "general.makeDefault": "Сделать по умолчанию",
"general.platformDestinationId": "ID назначения платформы",
"general.targetId": "ID цели",
"general.targetIdForNotify": "ID цели",
diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json
index 84a15b7..6a5999b 100644
--- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json
+++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "仅在我离开时",
"general.always": "始终",
"general.sendTest": "发送测试",
+ "general.makeDefault": "设为默认",
"general.platformDestinationId": "平台目标 ID",
"general.targetId": "目标 ID",
"general.targetIdForNotify": "目标 ID",
diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json
index 8301dd2..7df3a49 100644
--- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json
+++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json
@@ -412,6 +412,7 @@
"general.onlyWhenAway": "僅在我離開時",
"general.always": "一律",
"general.sendTest": "傳送測試",
+ "general.makeDefault": "設為預設",
"general.platformDestinationId": "平台目標 ID",
"general.targetId": "目標 ID",
"general.targetIdForNotify": "目標 ID",
diff --git a/cptr/routers/notifications.py b/cptr/routers/notifications.py
index 54c6639..4961cfc 100644
--- a/cptr/routers/notifications.py
+++ b/cptr/routers/notifications.py
@@ -12,6 +12,7 @@
get_notification_event_catalog,
get_bot_options,
list_targets,
+ set_default_target,
test_target,
update_target,
)
@@ -90,6 +91,14 @@ async def api_test_target(request: Request, target_id: str):
raise _error(exc) from exc
+@router.put("/targets/{target_id}/default")
+async def api_set_default_target(request: Request, target_id: str):
+ try:
+ return await set_default_target(_user_id(request), target_id)
+ except NotificationError as exc:
+ raise _error(exc) from exc
+
+
@router.get("/bot-options")
async def api_bot_options(request: Request):
return {
diff --git a/cptr/routers/state.py b/cptr/routers/state.py
index f15591a..8acb714 100644
--- a/cptr/routers/state.py
+++ b/cptr/routers/state.py
@@ -111,7 +111,8 @@ async def put_preferences(request: Request):
if not user_id:
return {"status": "skipped"}
body = await request.json()
- await UserStates.save_data(user_id, body)
+ current = await UserStates.get_data(user_id)
+ await UserStates.save_data(user_id, {**current, **body})
return {"status": "saved"}
diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py
index 3824789..d5fe390 100644
--- a/cptr/utils/notifications.py
+++ b/cptr/utils/notifications.py
@@ -64,6 +64,10 @@ def _public_target(target: dict) -> dict:
return public
+def _default_target_id(data: dict) -> str:
+ return str((data.get("notifications") or {}).get("default_target_id") or "")
+
+
def _validate_events(events) -> list[str]:
if events is None:
return []
@@ -110,13 +114,16 @@ def validate_webhook_url(url: str) -> str:
async def list_targets(user_id: str) -> list[dict]:
data = await _state(user_id)
visible = []
+ default_id = _default_target_id(data)
for target in _targets(data):
if target.get("type") == "bot":
try:
await _ensure_user_bot(user_id, str((target.get("config") or {}).get("bot_id") or ""))
except NotificationError:
continue
- visible.append(_public_target(target))
+ public = _public_target(target)
+ public["is_default"] = target.get("id") == default_id
+ visible.append(public)
return visible
@@ -221,8 +228,15 @@ async def create_target(user_id: str, payload: dict) -> dict:
await _ensure_user_bot(user_id, target["config"]["bot_id"])
_check_unique_id(targets, target["id"])
targets.append(target)
+ notifications = data.setdefault("notifications", {})
+ is_default = False
+ if not notifications.get("default_target_id"):
+ notifications["default_target_id"] = target["id"]
+ is_default = True
await _save_state(user_id, data)
- return _public_target(target)
+ public = _public_target(target)
+ public["is_default"] = is_default
+ return public
async def update_target(user_id: str, target_id: str, payload: dict) -> dict:
@@ -235,8 +249,13 @@ async def update_target(user_id: str, target_id: str, payload: dict) -> dict:
await _ensure_user_bot(user_id, target["config"]["bot_id"])
_check_unique_id(targets, target["id"], target_id)
targets[idx] = target
+ notifications = data.setdefault("notifications", {})
+ if notifications.get("default_target_id") == target_id:
+ notifications["default_target_id"] = target["id"]
await _save_state(user_id, data)
- return _public_target(target)
+ public = _public_target(target)
+ public["is_default"] = notifications.get("default_target_id") == target["id"]
+ return public
raise NotificationError("notification target not found")
@@ -247,10 +266,24 @@ async def delete_target(user_id: str, target_id: str) -> bool:
if len(kept) == len(targets):
return False
data["notifications"]["targets"] = kept
+ if data["notifications"].get("default_target_id") == target_id:
+ data["notifications"]["default_target_id"] = kept[0]["id"] if kept else None
await _save_state(user_id, data)
return True
+async def set_default_target(user_id: str, target_id: str) -> dict:
+ data = await _state(user_id)
+ for target in _targets(data):
+ if target.get("id") == target_id:
+ data.setdefault("notifications", {})["default_target_id"] = target_id
+ await _save_state(user_id, data)
+ public = _public_target(target)
+ public["is_default"] = True
+ return public
+ raise NotificationError("notification target not found")
+
+
async def _find_target(user_id: str, target_id: str) -> dict | None:
data = await _state(user_id)
needle = target_id.lower()
@@ -409,10 +442,15 @@ async def dispatch_notification_event(event: Event) -> None:
async def notify_target(
user_id: str,
- target_id: str,
message: str,
+ target_id: str | None = None,
title: str | None = None,
) -> str:
+ if not target_id:
+ data = await _state(user_id)
+ target_id = _default_target_id(data)
+ if not target_id:
+ raise NotificationError("no default notification target is set")
target = await _find_target(user_id, target_id)
if not target:
raise NotificationError(f'notification target "{target_id}" was not found')
diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py
index e758b50..62f3cd2 100644
--- a/cptr/utils/tools.py
+++ b/cptr/utils/tools.py
@@ -1887,11 +1887,11 @@ async def get_allowed_chat(cid: str):
)
-async def notify(target: str, message: str, title: str = "", *, __context__: dict) -> str:
- """Send a notification to a named user notification target.
+async def notify(message: str, target: str = "", title: str = "", *, __context__: dict) -> str:
+ """Send a notification to a user notification target.
- :param target: Notification target name from Settings > Notifications.
:param message: Message body to send.
+ :param target: Optional notification target ID from Settings > Notifications. Uses the default target when omitted.
:param title: Optional notification title.
"""
user_id = __context__.get("user_id")
@@ -1900,7 +1900,7 @@ async def notify(target: str, message: str, title: str = "", *, __context__: dic
try:
from cptr.utils.notifications import NotificationError, notify_target
- return await notify_target(user_id, target, message, title or None)
+ return await notify_target(user_id, message, target or None, title or None)
except NotificationError as exc:
return f"Error: {exc}"
except Exception as exc:
From 177992707237052c3a274b380bc9ee18ff9bbe00 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Mon, 6 Jul 2026 16:49:47 -0500
Subject: [PATCH 19/20] refac
---
cptr/utils/notifications.py | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py
index d5fe390..7c28916 100644
--- a/cptr/utils/notifications.py
+++ b/cptr/utils/notifications.py
@@ -309,19 +309,19 @@ def _sink_payload(url: str, payload: dict) -> dict:
title = payload["title"]
message = payload["message"]
if "hooks.slack.com" in url or "chat.googleapis.com" in url:
- return {"text": f"*{title}*\n{message}"}
+ return {"text": f"*{title}*\n{message}" if title else message}
if "discord.com/api/webhooks" in url:
- content = f"**{title}**\n{message}"
+ content = f"**{title}**\n{message}" if title else message
return {"content": content[:1997] + "..." if len(content) > 2000 else content}
if "webhook.office.com" in url:
return {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "0076D7",
- "summary": title,
+ "summary": title or "Computer",
"sections": [
{
- "activityTitle": title,
+ "activityTitle": title or "Computer",
"activitySubtitle": "Computer",
"facts": [{"name": "Message", "value": message}],
"markdown": True,
@@ -350,8 +350,8 @@ async def _send_bot(target: dict, title: str, message: str, context: dict) -> No
if not manager:
raise NotificationError("bot manager is not running")
workspace = (context.get("workspace") or {}).get("name") if isinstance(context.get("workspace"), dict) else ""
- header = f"[{title}] {workspace}".strip()
- text = f"{header}\n\n{message}".strip()
+ header = f"[{title}] {workspace}".strip() if title else workspace
+ text = f"{header}\n\n{message}".strip() if header else message
config = target.get("config") or {}
await manager.send_notification(config["bot_id"], config["destination_chat_id"], text)
@@ -459,7 +459,7 @@ async def notify_target(
await _deliver(
target,
EVENTS.NOTIFICATION_MANUAL.name,
- title or "Notification",
+ title or "",
message,
{"chat_id": None, "workspace": None, "user_id": user_id},
)
From 5b8139c1a66dc562683e0050ce54c616be07f5b5 Mon Sep 17 00:00:00 2001
From: Timothy Jaeryang Baek
Date: Mon, 6 Jul 2026 16:52:19 -0500
Subject: [PATCH 20/20] refac
---
CHANGELOG.md | 20 ++++++++++++++++++++
pyproject.toml | 2 +-
uv.lock | 2 +-
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95d15f2..85f904f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ 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.7.6] - 2026-07-06
+
+### Added
+
+- 🔔 **Notification targets.** Send chat alerts to a webhook or one of your messaging bots, choose when alerts are sent, and test each destination from Settings.
+- 🤖 **Cline agent support.** Add Cline as a coding agent and use it from chat alongside Codex, Claude Code, Cursor, Grok, and OpenCode.
+- 📦 **Offline install guide.** Added setup steps for installing Computer in places without internet access after download.
+
+### Changed
+
+- 🧭 **Easier workspace start.** Recent workspaces are easier to find, scan, and reopen from the home screen.
+- 🪟 **Better split tabs.** Drag tabs to the left, right, top, or bottom to make split panes, and move tabs between panes without losing them.
+- 🌍 **More complete labels.** Updated labels for new notification and agent settings across supported languages.
+
+### Fixed
+
+- 📎 **Cleaner dragging.** Dragging tabs no longer wakes up file upload areas or chat attachments by mistake.
+- 💬 **Messaging bot setup.** Slack, WhatsApp, and Signal bots can now be created from the same admin flow as Telegram and Discord.
+- ✅ **More reliable chat endings.** Chats now settle more consistently at the end of a run, which keeps final status and alerts in sync.
+
## [0.7.5] - 2026-07-02
### Added
diff --git a/pyproject.toml b/pyproject.toml
index f65d930..c3c3c4d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.7.5"
+version = "0.7.6"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
diff --git a/uv.lock b/uv.lock
index 194dfac..adc79f0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -284,7 +284,7 @@ wheels = [
[[package]]
name = "cptr"
-version = "0.7.5"
+version = "0.7.6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },