From e5ea8d72edb6aec6155299ec2cf9f145ff582c44 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 21:45:05 -0500 Subject: [PATCH 1/9] refac --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a8e0cf..a52bcdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ⚡ **Your model choices are ready sooner.** Computer now gets them ready as it opens and keeps the last working list if a check fails. - 👋 **More dependable restarts.** Computer now finishes its open work more reliably before it closes. + ## [0.9.3] - 2026-07-11 ### Changed From 48db0611a822600d02aa383a29f90a188f265416 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 21:57:30 -0500 Subject: [PATCH 2/9] refac --- .github/workflows/docker.yml | 71 +++++++++++++++++++++++++++------- Dockerfile | 22 +++++++++-- README.md | 1 + cptr/utils/browser/launcher.py | 4 +- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2ad3a953..4246e204 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -47,9 +47,23 @@ jobs: - platform: linux/amd64 runner: ubuntu-latest arch: amd64 + target: default + flavor: default - platform: linux/arm64 runner: ubuntu-24.04-arm arch: arm64 + target: default + flavor: default + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + target: browser + flavor: browser + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + target: browser + flavor: browser runs-on: ${{ matrix.runner }} steps: - name: Checkout @@ -70,10 +84,11 @@ jobs: uses: docker/build-push-action@v6 with: context: . + target: ${{ matrix.target }} platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=dev-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=dev-${{ matrix.arch }} + cache-from: type=gha,scope=dev-${{ matrix.flavor }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dev-${{ matrix.flavor }}-${{ matrix.arch }} - name: Export digest run: | @@ -84,7 +99,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-dev-${{ matrix.arch }} + name: digests-dev-${{ matrix.flavor }}-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 @@ -92,13 +107,16 @@ jobs: merge-dev: if: github.ref == 'refs/heads/main' needs: build-dev + strategy: + matrix: + flavor: [default, browser] runs-on: ubuntu-latest steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests - pattern: digests-dev-* + pattern: digests-dev-${{ matrix.flavor }}-* merge-multiple: true - name: Set up Docker Buildx @@ -115,9 +133,12 @@ jobs: working-directory: /tmp/digests run: | SHORT_SHA="${GITHUB_SHA::7}" - docker buildx imagetools create \ - -t "${{ env.IMAGE }}:dev" \ - -t "${{ env.IMAGE }}:dev-${SHORT_SHA}" \ + if [ "${{ matrix.flavor }}" = default ]; then + tags=("${{ env.IMAGE }}:dev" "${{ env.IMAGE }}:dev-${SHORT_SHA}") + else + tags=("${{ env.IMAGE }}:browser-dev" "${{ env.IMAGE }}:browser-dev-${SHORT_SHA}") + fi + docker buildx imagetools create $(printf -- '-t %s ' "${tags[@]}") \ $(printf '${{ env.IMAGE }}@sha256:%s ' *) # ── Release builds (release branch) ─────────────────────────────────────── @@ -131,9 +152,23 @@ jobs: - platform: linux/amd64 runner: ubuntu-latest arch: amd64 + target: default + flavor: default - platform: linux/arm64 runner: ubuntu-24.04-arm arch: arm64 + target: default + flavor: default + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + target: browser + flavor: browser + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + target: browser + flavor: browser runs-on: ${{ matrix.runner }} steps: - name: Checkout @@ -154,10 +189,11 @@ jobs: uses: docker/build-push-action@v6 with: context: . + target: ${{ matrix.target }} platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=release-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} + cache-from: type=gha,scope=release-${{ matrix.flavor }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=release-${{ matrix.flavor }}-${{ matrix.arch }} - name: Export digest run: | @@ -168,7 +204,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-release-${{ matrix.arch }} + name: digests-release-${{ matrix.flavor }}-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 @@ -176,13 +212,16 @@ jobs: merge-release: needs: [read-version, build-release] if: github.ref == 'refs/heads/release' + strategy: + matrix: + flavor: [default, browser] runs-on: ubuntu-latest steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests - pattern: digests-release-* + pattern: digests-release-${{ matrix.flavor }}-* merge-multiple: true - name: Set up Docker Buildx @@ -200,8 +239,10 @@ jobs: run: | VERSION="${{ needs.read-version.outputs.version }}" MAJOR_MINOR="${VERSION%.*}" - docker buildx imagetools create \ - -t "${{ env.IMAGE }}:latest" \ - -t "${{ env.IMAGE }}:${VERSION}" \ - -t "${{ env.IMAGE }}:${MAJOR_MINOR}" \ + if [ "${{ matrix.flavor }}" = default ]; then + tags=("${{ env.IMAGE }}:latest" "${{ env.IMAGE }}:${VERSION}" "${{ env.IMAGE }}:${MAJOR_MINOR}") + else + tags=("${{ env.IMAGE }}:browser" "${{ env.IMAGE }}:${VERSION}-browser" "${{ env.IMAGE }}:${MAJOR_MINOR}-browser") + fi + docker buildx imagetools create $(printf -- '-t %s ' "${tags[@]}") \ $(printf '${{ env.IMAGE }}@sha256:%s ' *) diff --git a/Dockerfile b/Dockerfile index 25762cc5..90ada636 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,15 +22,15 @@ COPY --from=frontend-builder /build/frontend/build cptr/frontend/build RUN uv build --wheel --out-dir /dist -# ── Stage 3: Minimal runtime image ───────────────────────── +# ── Stage 3: Shared runtime ──────────────────────────────── FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS runtime LABEL org.opencontainers.image.source="https://github.com/open-webui/computer" -LABEL org.opencontainers.image.description="cptr: your computer, from anywhere" +LABEL org.opencontainers.image.description="Open WebUI Computer" -# Runtime deps: git for git operations, tini for PID 1 +# Runtime deps shared by default and browser images. RUN apt-get update && \ - apt-get install -y --no-install-recommends git tini && \ + apt-get install -y --no-install-recommends gh git tini && \ rm -rf /var/lib/apt/lists/* # Create non-root user and writable data directory @@ -55,3 +55,17 @@ VOLUME ["/data"] ENTRYPOINT ["tini", "--"] CMD ["cptr", "run", "--host", "0.0.0.0", "--port", "8000", "--headless"] + + +# ── Browser image: Chromium for agent browser automation ─── +FROM runtime AS browser + +USER root +RUN apt-get update && \ + apt-get install -y --no-install-recommends chromium && \ + rm -rf /var/lib/apt/lists/* +USER cptr + + +# ── Default image ────────────────────────────────────────── +FROM runtime AS default diff --git a/README.md b/README.md index 01166942..9a7302db 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ docker run --rm -it \ Then open the URL printed in the logs, usually `http://localhost:8000/?token=...`. Open WebUI Computer stores its state in `/data`. Mount your project into the container, like `-v "$PWD:/workspace"`, so Open WebUI Computer can access it. +The default image includes Git and GitHub CLI (`gh`). Use `ghcr.io/open-webui/computer:browser` when agent browser automation needs Chromium. If you bind-mount a host directory to `/data`, make sure that directory is writable by the container user. SQLite needs to create and update `/data/app.db`, and host directory permissions take precedence over the image's built-in `/data` ownership. diff --git a/cptr/utils/browser/launcher.py b/cptr/utils/browser/launcher.py index ccf1b598..90f0b284 100644 --- a/cptr/utils/browser/launcher.py +++ b/cptr/utils/browser/launcher.py @@ -134,8 +134,10 @@ async def ensure_browser(port: int = 9222) -> str: "--disable-translate", "--disable-extensions", f"--user-data-dir={_user_data_dir}", - "about:blank", ] + if Path("/.dockerenv").exists(): + args.append("--no-sandbox") + args.append("about:blank") logger.info("Launching Chrome: %s", " ".join(args[:3])) From 10ecaae58929b99765067699c5a654fdc846cc5c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 23:12:02 -0500 Subject: [PATCH 3/9] refac --- cptr/frontend/src/lib/apis/chat.ts | 1 + .../components/SidebarWorkspaceList.svelte | 15 ++++++++++++ .../src/lib/components/chat/ChatPanel.svelte | 23 ++++++++++++++++++ .../src/lib/components/common/ChatItem.svelte | 17 +++++++++++-- .../versions/0004_add_chat_last_read_at.py | 24 +++++++++++++++++++ cptr/models/chats.py | 14 +++++++++++ cptr/routers/chat.py | 2 ++ cptr/socket/main.py | 17 ++++++++++++- 8 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 cptr/migrations/versions/0004_add_chat_last_read_at.py diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 464513ce..a5c8339d 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -25,6 +25,7 @@ export interface ChatInfo { current_message_id: string | null; created_at: number; updated_at: number; + last_read_at: number | null; is_active?: boolean; } diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index c6f539aa..7eebbb99 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -36,6 +36,7 @@ let wsChatsHasMore = $state>(new Map()); let wsChatsLoading = $state>(new Set()); let currentPath = $derived($page.url.searchParams.get('workspace')); + let currentChatId = $derived($page.url.searchParams.get('chatId')); function toggleWorkspaceExpand(path: string) { const next = new Set(expandedWorkspaces); @@ -163,7 +164,20 @@ title?: string; delta?: string; workspace?: string; + last_read_at?: number; }) { + if (typeof data.last_read_at === 'number') { + wsChatsCache = new Map( + [...wsChatsCache].map(([path, chats]) => [ + path, + chats.map((chat) => + chat.id === data.chat_id ? { ...chat, last_read_at: data.last_read_at! } : chat + ) + ]) + ); + return; + } + const isNew = !seenChatIds.has(data.chat_id); seenChatIds.add(data.chat_id); if (!data.done && !data.title && !isNew) return; @@ -314,6 +328,7 @@ {#each chats as chat (chat.id)} openChat(chat.id, ws.path)} onmenu={(e) => openChatMenu(e, chat.id, ws.path)} /> diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index ca79de61..f3f6d45c 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -383,10 +383,16 @@ // ── Load chat from DB ─────────────────────────────────────── let loadGeneration = 0; + let loadedChatId = $state(null); + + function markChatRead(id: string) { + socketStore.getSocket()?.emit('chat:read', { chat_id: id }); + } async function loadChat(id: string) { if (chatId && chatId !== id) stopTtsPlayback(); chatId = id; + loadedChatId = null; const gen = ++loadGeneration; // Only show loading spinner on initial load (no messages yet). // On reloads (e.g. after cancel/done), keep the DOM intact to preserve scroll position. @@ -416,6 +422,7 @@ chatTitle = data.chat.title; updateTab(tabId, id, data.chat.title); } + loadedChatId = id; } finally { if (isInitialLoad && gen === loadGeneration) loading = false; } @@ -756,6 +763,22 @@ return () => unregisterStreamingChat(chatId!); }); + // ── Persist read state separately from visibility tracking ── + + $effect(() => { + if (!chatId || loadedChatId !== chatId || !active) return; + const id = chatId; + const send = () => markChatRead(id); + send(); + return socketStore.on('connect', send); + }); + + $effect(() => { + if (!chatId || !active) return; + const id = chatId; + return () => markChatRead(id); + }); + $effect(() => { if (!chatId || !tabId) return; const send = () => diff --git a/cptr/frontend/src/lib/components/common/ChatItem.svelte b/cptr/frontend/src/lib/components/common/ChatItem.svelte index 5a5e1937..50cc4861 100644 --- a/cptr/frontend/src/lib/components/common/ChatItem.svelte +++ b/cptr/frontend/src/lib/components/common/ChatItem.svelte @@ -10,11 +10,17 @@ interface Props { chat: ChatInfo; + isSelected?: boolean; onclick: () => void; /** Optional menu button click handler */ onmenu?: (e: MouseEvent) => void; } - let { chat, onclick, onmenu }: Props = $props(); + let { chat, isSelected = false, onclick, onmenu }: Props = $props(); + let unread = $derived( + !isSelected && + !chat.is_active && + (chat.last_read_at === null || chat.updated_at > chat.last_read_at) + ); function formatTime(ts: number): string { const now = Date.now(); @@ -47,7 +53,14 @@ {#if chat.is_active} {/if} - {chat.title} + {#if unread} + + {/if} + {chat.title} {formatTime(chat.updated_at)} diff --git a/cptr/migrations/versions/0004_add_chat_last_read_at.py b/cptr/migrations/versions/0004_add_chat_last_read_at.py new file mode 100644 index 00000000..2d7aeb4d --- /dev/null +++ b/cptr/migrations/versions/0004_add_chat_last_read_at.py @@ -0,0 +1,24 @@ +"""add chat last read at + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-07-11 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("chats", sa.Column("last_read_at", sa.BigInteger(), nullable=True)) + op.execute("UPDATE chats SET last_read_at = updated_at") + + +def downgrade() -> None: + op.drop_column("chats", "last_read_at") diff --git a/cptr/models/chats.py b/cptr/models/chats.py index 0624bbbe..f5be8cb6 100644 --- a/cptr/models/chats.py +++ b/cptr/models/chats.py @@ -29,6 +29,7 @@ class Chat(Base): meta = Column(JSON, nullable=True) # {model_id, connection_id, pinned, archived, ...} created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) + last_read_at = Column(BigInteger, nullable=True) # ── Class methods ──────────────────────────────────────── @@ -61,6 +62,7 @@ async def create( meta=meta, created_at=created_at, updated_at=created_at, + last_read_at=created_at, ) db.add(chat) await db.commit() @@ -109,6 +111,18 @@ async def update_current_message( await db.commit() return result.rowcount > 0 + @staticmethod + async def update_last_read_at(chat_id: str, user_id: str, last_read_at: int) -> bool: + """Mark a chat read without changing its activity timestamp.""" + async with await get_db() as db: + result = await db.execute( + update(Chat) + .where(Chat.id == chat_id, Chat.user_id == user_id) + .values(last_read_at=last_read_at) + ) + await db.commit() + return result.rowcount > 0 + @staticmethod async def delete(chat_id: str) -> bool: async with await get_db() as db: diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 8017f27e..cdb31c37 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -180,6 +180,7 @@ def _scan_chat_files() -> list[dict]: "current_message_id": chat.current_message_id, "created_at": chat.created_at, "updated_at": listing_updated_at, + "last_read_at": chat.last_read_at, "is_active": chat.id in active_ids, } ) @@ -402,6 +403,7 @@ async def get_chat( "current_message_id": chat.current_message_id, "created_at": chat.created_at, "updated_at": chat.updated_at, + "last_read_at": chat.last_read_at, }, "messages": [_message_dict(m) for m in messages], "tasks": tasks, diff --git a/cptr/socket/main.py b/cptr/socket/main.py index c22bd630..10c4d52e 100644 --- a/cptr/socket/main.py +++ b/cptr/socket/main.py @@ -9,7 +9,8 @@ import socketio -from cptr.utils.config import check_access +from cptr.models import Chat +from cptr.utils.config import check_access, now_ms from cptr.env import CORS_ALLOWED_ORIGINS sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=CORS_ALLOWED_ORIGINS) @@ -77,6 +78,20 @@ async def on_chat_view(sid, data): del _chat_view_sids[(user_id, chat_id)] +@sio.on("chat:read") +async def on_chat_read(sid, data): + """Persist a user's explicit read acknowledgement for a chat.""" + session = await sio.get_session(sid) + user_id = session.get("user_id") + chat_id = data.get("chat_id") if isinstance(data, dict) else None + if not user_id or not isinstance(chat_id, str): + return + + last_read_at = now_ms() + if await Chat.update_last_read_at(chat_id, user_id, last_read_at): + await emit_to_user(user_id, {"chat_id": chat_id, "last_read_at": last_read_at}) + + async def emit_to_user(user_id: str, data: dict): """Send events:chat to all of a user's connected tabs/windows.""" for sid in list(_user_sids.get(user_id, set())): From ba32b68412b1232c2555929ee98c356e6a5bdaca Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 23:33:10 -0500 Subject: [PATCH 4/9] refac --- .../src/lib/components/GroupTabBar.svelte | 9 +- .../components/SidebarWorkspaceList.svelte | 67 ++++++---- .../src/lib/components/chat/ChatPanel.svelte | 8 +- .../src/lib/components/common/ChatItem.svelte | 10 +- cptr/frontend/src/lib/stores/chat.ts | 114 ++++++++++++++++-- cptr/routers/chat.py | 7 +- cptr/utils/chat_task.py | 26 ++++ 7 files changed, 201 insertions(+), 40 deletions(-) diff --git a/cptr/frontend/src/lib/components/GroupTabBar.svelte b/cptr/frontend/src/lib/components/GroupTabBar.svelte index 3a8933ee..0c5f9fcc 100644 --- a/cptr/frontend/src/lib/components/GroupTabBar.svelte +++ b/cptr/frontend/src/lib/components/GroupTabBar.svelte @@ -21,7 +21,7 @@ type Tab } from '$lib/stores'; import { openChatTab } from '$lib/stores'; - import { chatEnabled, streamingChatTabs } from '$lib/stores/chat'; + import { chatEnabled, chatStatuses, isChatUnread, streamingChatTabs } from '$lib/stores/chat'; import { voiceMemosEnabled, showVoiceMemo } from '$lib/stores/audio'; import { keybindings, formatChord } from '$lib/stores/keybindings'; import Icon from './Icon.svelte'; @@ -407,6 +407,8 @@
{#each displayTabs as tab (tab.id)} {@const isActive = tab.id === group.activeTabId} + {@const chatStatus = + tab.type === 'chat' && tab.path ? $chatStatuses.get(tab.path) : undefined}