diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index c6c1ff43..1dd45692 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -14,6 +14,9 @@ permissions: jobs: build: runs-on: ubuntu-latest + outputs: + skip: ${{ steps.check.outputs.skip }} + version: ${{ steps.pkg.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -69,6 +72,20 @@ jobs: if: steps.check.outputs.skip != 'true' run: python -m build --wheel + - name: Build Linux wheelhouse + if: steps.check.outputs.skip != 'true' + run: | + VERSION="${{ steps.pkg.outputs.version }}" + WHEEL="dist/cptr-${VERSION}-py3-none-any.whl" + mkdir -p wheelhouse + python -m pip download --dest wheelhouse "${WHEEL}[all]" + tar -czf "cptr-${VERSION}-linux-wheelhouse.tar.gz" -C wheelhouse . + + - name: Generate checksums + if: steps.check.outputs.skip != 'true' + run: | + sha256sum dist/*.whl cptr-*-linux-wheelhouse.tar.gz > SHA256SUMS + - name: Upload dist artifacts if: steps.check.outputs.skip != 'true' uses: actions/upload-artifact@v4 @@ -76,12 +93,23 @@ jobs: name: dist path: dist/ + - name: Upload release artifacts + if: steps.check.outputs.skip != 'true' + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: | + dist/*.whl + cptr-*-linux-wheelhouse.tar.gz + SHA256SUMS + publish: needs: build if: needs.build.outputs.skip != 'true' runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: - name: Download dist artifacts @@ -90,7 +118,28 @@ jobs: name: dist path: dist/ + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-assets + path: release-assets/ + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 # No API token needed - uses OIDC trusted publishing. # Configure at: https://pypi.org/manage/project/cptr/settings/publishing/ + + - name: Upload GitHub release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.build.outputs.version }} + run: | + for attempt in {1..12}; do + if gh release view "v${VERSION}" >/dev/null 2>&1; then + gh release upload "v${VERSION}" release-assets/* --clobber + exit 0 + fi + sleep 10 + done + echo "GitHub release v${VERSION} was not found" >&2 + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d15f22..85f904f5 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/README.md b/README.md index 11d27b0e..b7bbcd00 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. @@ -121,7 +121,7 @@ Open WebUI Computer exposes an OpenAI-compatible API (`/v1/chat/completions`). A |---|---| | ๐ŸŽ™๏ธ **Voice memos** | Record audio, auto-transcribe to markdown. | | ๐Ÿ’ฌ **Message queue** | Queue follow-up messages while the AI is responding. | -| ๐Ÿ”” **Notifications** | Browser notifications and webhooks (Slack, Discord, Teams) when tasks finish. | +| ๐Ÿ”” **Notifications** | Browser notifications plus named webhook or bot targets for chat events. | | ๐Ÿ“Š **Usage** | Token counts and timing on every response. | | ๐Ÿ“„ **System prompts** | Per-model, per-workspace, or global. Template variables included. | | ๐Ÿ“‹ **Audit logging** | Structured audit trail of all API mutations with automatic redaction of sensitive data. | @@ -170,6 +170,38 @@ If you bind-mount a host directory to `/data`, make sure that directory is writa The `:dev` image is also available and tracks the `main` branch. +## Air-gapped installation + +Open WebUI Computer does not need internet access after it is installed. The Python wheel includes the built frontend assets, and the Docker image is self-contained. + +On a connected machine: + +```bash +pip download --dest wheelhouse 'cptr[all]' +docker pull ghcr.io/open-webui/computer:latest +docker save ghcr.io/open-webui/computer:latest -o cptr-image.tar +``` + +Transfer the artifacts, then install or run offline: + +```bash +python -m venv .venv +. .venv/bin/activate +pip install --no-index --find-links ./wheelhouse 'cptr[all]' +cptr run --host 0.0.0.0 + +docker load -i cptr-image.tar +docker run --rm -it \ + --network=none \ + -p 8000:8000 \ + -v cptr-data:/data \ + -v "$PWD:/workspace" \ + -w /workspace \ + ghcr.io/open-webui/computer:latest +``` + +Core local features run from local assets. External services such as hosted model APIs, web search providers, messaging adapters, Git remotes, and MCP/OpenAPI servers still require reachable endpoints. + ## Security model Open WebUI Computer is designed as **your computer, served to you**. Once authenticated, a user has full access to the host filesystem and shell, equivalent to an SSH session. There is no path sandboxing and no per-user isolation. diff --git a/cptr/app.py b/cptr/app.py index 6607de15..e9412dc1 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -19,6 +19,7 @@ git_router, images_router, memory_router, + notifications_router, proxy_router, search_router, skills_router, @@ -267,6 +268,7 @@ async def get_config(): app.include_router(git_router) app.include_router(images_router) app.include_router(memory_router) +app.include_router(notifications_router) app.include_router(proxy_router) app.include_router(search_router) app.include_router(skills_router) diff --git a/cptr/events.py b/cptr/events.py new file mode 100644 index 00000000..d871b956 --- /dev/null +++ b/cptr/events.py @@ -0,0 +1,223 @@ +"""Application event definitions and internal event bus.""" + +from __future__ import annotations + +import logging +import time +import uuid +from dataclasses import asdict, dataclass, field +from importlib.metadata import PackageNotFoundError, version as package_version +from typing import Any + +logger = logging.getLogger(__name__) + +MAX_STRING_LENGTH = 1000 + + +@dataclass(frozen=True) +class EventDefinition: + name: str + description: str | None = None + message: str | None = None + + @property + def label(self) -> str: + return self.message or self.name.replace(".", " ").replace("_", " ").title() + + +class EventDefinitions: + CHAT_FINISHED = EventDefinition( + "chat.finished", + "A chat run finished successfully.", + "Chat finished", + ) + CHAT_FAILED = EventDefinition( + "chat.failed", + "A chat run failed.", + "Chat failed", + ) + NOTIFICATION_TEST = EventDefinition( + "notification.test", + "A notification target test was sent.", + "Test notification", + ) + NOTIFICATION_MANUAL = EventDefinition( + "manual.notify", + "A manual notification was sent.", + "Notification", + ) + + +EVENTS = EventDefinitions() +EVENT_DEFINITIONS = tuple( + value for value in vars(EventDefinitions).values() if isinstance(value, EventDefinition) +) +EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS} +EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS) +EVENT_CATALOG_SET = set(EVENT_CATALOG) + +CHAT_NOTIFICATION_EVENTS = ( + EVENTS.CHAT_FINISHED, + EVENTS.CHAT_FAILED, +) + +SENSITIVE_KEYS = { + "password", + "hashed_password", + "token", + "access_token", + "refresh_token", + "id_token", + "api_key", + "secret", + "key", + "authorization", + "cookie", + "webhook_token", +} +SAFE_ACTOR_FIELDS = ("id", "name", "email", "role", "created_at", "updated_at") + + +@dataclass +class Event: + schema: str + id: str + event: str + resource: str + operation: str + created_at: int + source: str + actor: dict[str, Any] | None = None + subject: dict[str, Any] | None = None + data: dict[str, Any] = field(default_factory=dict) + message: str | None = None + + def model_dump(self) -> dict[str, Any]: + return asdict(self) + + +def _sanitize(value: Any) -> Any: + if hasattr(value, "model_dump"): + value = value.model_dump() + + if isinstance(value, dict): + sanitized = {} + for key, item in value.items(): + normalized = str(key).lower().replace("-", "_") + if ( + normalized in SENSITIVE_KEYS + or normalized.endswith("_token") + or normalized.endswith("_secret") + or normalized.endswith("_api_key") + or normalized.endswith("_key") + ): + continue + sanitized[key] = _sanitize(item) + return sanitized + + if isinstance(value, (list, tuple, set)): + return [_sanitize(item) for item in value] + + if isinstance(value, str) and len(value) > MAX_STRING_LENGTH: + return f"{value[:MAX_STRING_LENGTH]}..." + + return value + + +def build_event( + event: EventDefinition | str, + *, + actor: Any | None = None, + subject_id: Any | None = None, + subject_type: str | None = None, + source: str = "api", + data: dict | None = None, + message: str | None = None, +) -> Event: + event_name_value = event.name if isinstance(event, EventDefinition) else str(event) + if event_name_value not in EVENT_CATALOG_SET: + raise ValueError(f"Unknown event: {event_name_value}") + + parts = event_name_value.split(".") + resource = ".".join(parts[:-1]) + subject = ( + {"type": subject_type or resource, "id": subject_id} + if subject_id is not None or subject_type is not None + else None + ) + actor = _sanitize(actor) + actor_payload = None + if actor: + get = actor.get if isinstance(actor, dict) else lambda key: getattr(actor, key, None) + actor_payload = {field: get(field) for field in SAFE_ACTOR_FIELDS if get(field) is not None} + if actor_payload: + actor_payload["type"] = get("type") or "user" + + try: + schema = package_version("cptr") + except PackageNotFoundError: + schema = "dev" + + return Event( + schema=schema, + id=str(uuid.uuid4()), + event=event_name_value, + resource=resource, + operation=parts[-1], + created_at=int(time.time()), + source=source, + actor=actor_payload, + subject=_sanitize(subject) if subject else None, + data=_sanitize(data or {}), + message=message, + ) + + +def get_event_catalog() -> list[dict[str, str]]: + return [ + { + "event": definition.name, + "label": definition.label, + "description": definition.description or "", + } + for definition in EVENT_DEFINITIONS + ] + + +class NotificationEventSink: + async def handle_event(self, event: Event) -> None: + from cptr.utils.notifications import dispatch_notification_event + + await dispatch_notification_event(event) + + +EVENT_SINKS = [NotificationEventSink()] + + +async def publish_event( + event: EventDefinition | str, + *, + actor: Any | None = None, + subject_id: Any | None = None, + subject_type: str | None = None, + source: str = "api", + data: dict | None = None, + message: str | None = None, +) -> Event: + event_payload = build_event( + event, + actor=actor, + subject_id=subject_id, + subject_type=subject_type, + source=source, + data=data, + message=message, + ) + + for sink in EVENT_SINKS: + try: + await sink.handle_event(event_payload) + except Exception: + logger.exception("Event sink failed for %s", event_payload.event) + + return event_payload diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index 4b10260c..00ae9ae8 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); diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index b2912a32..7aa26275 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/apis/notifications.ts b/cptr/frontend/src/lib/apis/notifications.ts new file mode 100644 index 00000000..3442e067 --- /dev/null +++ b/cptr/frontend/src/lib/apis/notifications.ts @@ -0,0 +1,88 @@ +import { fetchJSON, jsonBody } from '$lib/apis'; + +export const CHAT_NOTIFICATION_EVENTS = { + FINISHED: 'chat.finished', + FAILED: 'chat.failed' +} as const; + +export type ChatNotificationEvent = + (typeof CHAT_NOTIFICATION_EVENTS)[keyof typeof CHAT_NOTIFICATION_EVENTS]; +export type NotificationDelivery = 'away' | 'always'; +export type NotificationTargetType = 'webhook' | 'bot'; + +export interface NotificationEventOption { + event: ChatNotificationEvent; + label: string; + description: string; +} + +export interface NotificationTarget { + id: string; + type: NotificationTargetType; + enabled: boolean; + is_default?: boolean; + events: ChatNotificationEvent[]; + delivery: NotificationDelivery; + config: { + url_masked?: string; + bot_id?: string; + destination_chat_id?: string; + }; + created_at: number; + updated_at: number; +} + +export interface BotOption { + id: string; + name: string; + platform: string; + is_active: boolean; + is_running: boolean; +} + +export interface NotificationTargetPayload { + id?: string; + type?: NotificationTargetType; + enabled?: boolean; + events?: ChatNotificationEvent[]; + delivery?: NotificationDelivery; + config?: Record; +} + +export async function listNotificationTargets(): Promise { + const data = await fetchJSON<{ targets: NotificationTarget[] }>('/api/notifications/targets'); + return data.targets; +} + +export async function listNotificationEvents(): Promise { + const data = await fetchJSON<{ events: NotificationEventOption[] }>('/api/notifications/events'); + return data.events; +} + +export function createNotificationTarget(payload: NotificationTargetPayload) { + return fetchJSON('/api/notifications/targets', jsonBody(payload)); +} + +export function updateNotificationTarget(id: string, payload: NotificationTargetPayload) { + return fetchJSON(`/api/notifications/targets/${id}`, { + ...jsonBody(payload), + method: 'PUT' + }); +} + +export function deleteNotificationTarget(id: string) { + return fetchJSON<{ ok: boolean }>(`/api/notifications/targets/${id}`, { method: 'DELETE' }); +} + +export function testNotificationTarget(id: string) { + return fetchJSON<{ ok: boolean }>(`/api/notifications/targets/${id}/test`, { method: 'POST' }); +} + +export function setDefaultNotificationTarget(id: string) { + return fetchJSON(`/api/notifications/targets/${id}/default`, { method: 'PUT' }); +} + +export async function listNotificationBotOptions(): Promise { + const data = await fetchJSON<{ bots: BotOption[] }>('/api/notifications/bot-options'); + return data.bots; +} diff --git a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte index 57ffe39d..93d1986b 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 5b20b124..a1289929 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]; } diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte index be0c803b..d9c2a729 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 b76178d3..165d4174 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} - diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte new file mode 100644 index 00000000..5f9d803e --- /dev/null +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -0,0 +1,472 @@ + + +
+
+

+ {$t('general.notifications')} +

+ +
+ +

+ {$t('general.browserNotificationsDesc')} +

+ + + +
+ + {$t('general.notificationTargets')} + + +
+ + {#if loadingTargets} +

{$t('common.loading')}

+ {:else if !targets.length} +

+ {$t('general.noNotificationTargets')} +

+ {:else} +
+ {#each targets as target} + {@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')} + + {#if target.is_default} + + {$t('general.default')} + + {/if} +
+
+ {targetDestination} +
+
+ {alertLabels || $t('general.noChatAlerts')} + {#if target.events.length} + ยท {target.delivery === 'away' ? $t('general.onlyWhenAway') : $t('general.always')} + {/if} +
+
+ +
+ + {#if !target.is_default} + + {/if} + + +
+ +
+ patchTarget(target, { enabled: v })} + /> +
+
+ {/each} +
+ {/if} +
+
+
+ +{#if formOpen} + (formOpen = false)} class="w-full max-w-md mx-4"> +
+

+ {editingId ? $t('common.edit') : $t('general.addNotificationTarget')} +

+ +
+ {#each targetTypes as type} + + {/each} +
+ + + + + {#if form.type === 'webhook'} + + + {:else} + + + + + + {/if} + + +
+ {#each eventOptions as event} + + {/each} +
+ + {#if form.events.length} + +
+ {#each ['away', 'always'] as mode} + + {/each} +
+ {/if} + +

+ {$t('general.notifyToolAlwaysSends')} +

+ +
+ + +
+
+
+{/if} + + diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index 6c8c85ba..f622db43 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -3,6 +3,7 @@ import Icon from './Icon.svelte'; import Modal from './Modal.svelte'; import General from './Settings/General.svelte'; + import Notifications from './Settings/Notifications.svelte'; import Appearance from './Settings/Appearance.svelte'; import Memory from './Settings/Memory.svelte'; import PWA from './Settings/PWA.svelte'; @@ -25,6 +26,7 @@ type Tab = | 'general' + | 'notifications' | 'appearance' | 'memory' | 'pwa' @@ -76,6 +78,7 @@ const tabs: SettingsTab[] = [ { id: 'general', label: $t('settings.general'), icon: 'settings' }, { id: 'appearance', label: $t('settings.appearance'), icon: 'sun-light' }, + { id: 'notifications', label: $t('general.notifications'), icon: 'chat-bubble' }, { id: 'keyboard', label: $t('settings.keyboard'), icon: 'terminal' }, { id: 'account', label: $t('settings.account'), icon: 'user' } ]; @@ -124,7 +127,7 @@