openvibers/openvibe.live Commits
Vibe coding feeds move to OpenVibe.Chat: the channel chat frames Chat's feed card, the stream workspace links to openvibe.chat/feeds, Live's feed server, routes and scope are gone (410s say where) (#351)
Changes
@@@ -7,7 +7,7 @@## Features- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS--- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed++- [Vibe Coding](vibe-coding.md) — moved to OpenVibe Chat feeds (openvibe.chat/feeds); what Live answers now- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation- [API Tokens](api-tokens.md) — Bot/integration authentication- [The Rundown](rundown.md) — What streamers talked about, written up: topics, takes in their own words, episodes (replaced the Arena, 2026-10-19)@@@ -8,7 +8,7 @@ API tokens provide programmatic access to OpenVibe.Live for bots, integrations,- Each user can create up to **10 active tokens**- Tokens are hashed with SHA-256 before storage — the raw token is shown only once at creation- Tokens support scoped permissions and optional expiration--- The dashboard exposes integration presets, including a dedicated vibe-coding publisher preset++- The dashboard exposes integration presets## Creating a Token@@@ -47,12 +47,10 @@ Response:| `read` | Read streams, VODs, user info || `stream` | Start/stop streams, update stream info || `control` | Hardware control bridge access |--| `vibe_coding_publish` | Publish sanitized coding-feed events to `/ws/vibe-coding/publish` |## Recommended Presets- `Chat Bot` → `chat`, `read`--- `GitHub Copilot Companion` → `read`, `vibe_coding_publish`- `Stream Controller` → `read`, `stream`, `control`## Using a Token@@@ -68,7 +66,7 @@ curl https://openvibe.live/api/streams \### Vibe Coding Publisher--For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with the `vibe_coding_publish` scope. It is the only scope `/ws/vibe-coding/publish` and the `/api/vibe-coding` writes accept; a `stream` token is refused there.++Vibe coding feeds moved to OpenVibe Chat on 2026-10-24 ([docs/vibe-coding.md](vibe-coding.md)). A feed has its own key from [openvibe.chat/feeds](https://openvibe.chat/feeds), so Live API tokens no longer publish there.### WebSocket (Chat)@@@ -137,7 +135,6 @@ ws.on('message', (data) => {- Treat API tokens like passwords — never commit them to source control- Use the narrowest scope necessary for your use case--- Prefer `vibe_coding_publish` over `stream` for coding-feed publishers so tokens stay least-privilege- Set an expiration for tokens used in shared environments- Revoke tokens immediately if compromised- The `last_used_at` field in the token list helps identify unused tokens for cleanup@@@ -10,7 +10,7 @@ Your RTMP stream key is shown (hidden by default) for use with OBS, Streamlabs,Create long-lived API tokens for bots and integrations. Tokens authenticate via the `Authorization: Bearer hbt_...` header for REST API and chat WebSocket connections.--Available scopes: `chat`, `read`, `stream`, `vibe_coding_publish`.++Available scopes: `chat`, `read`, `stream`. (A vibe coding feed has its own key on openvibe.chat/feeds since 2026-10-24.)See [API Tokens](api-tokens.md) for full details.@@@ -1,283 +1,21 @@# Vibe Coding--OpenVibe.Live's vibe-coding feature lets a streamer publish a sanitized coding activity feed from an editor extension, CLI tool, agent, or other publisher into a managed stream slot. Viewers see a compact widget above chat, and the slot owner controls depth, visibility, and delay per slot.++Vibe coding feeds moved to **OpenVibe Chat** on 2026-10-24: make one at [openvibe.chat/feeds](https://openvibe.chat/feeds). A feed shows what you are building, live: Claude Code, GitHub Copilot or any tool sends each step (a prompt, a command, a file edited, the answer), and viewers follow along in a card above your channel's chat while you stream, in a Chat room, or embedded on your own site.--GitHub Copilot is the reference publisher we provide today, but the protocol is intentionally provider-agnostic so other integrations such as Claude Code or custom tooling can emit the same canonical event model.++## What changed for streamers--## Data Model++- **Set up on Chat.** Make a feed on openvibe.chat/feeds and choose the channel slot it shows on. The card appears above that slot's chat whenever the slot is live. Live's stream workspace links there.++- **Claude Code needs no extension.** The feed page gives a `.claude/settings.json` block of Claude Code `http` hooks. Each prompt, tool use and answer is posted with your feed's key, and the hooks never block Claude Code.++- **Any other tool** posts the same canonical events (`session.status`, `prompt`, `response`, `thinking`, `tool.call`, `file.change`, `file.save`) to `POST https://openvibe.chat/api/chat/feeds/<feed>/events` with `Authorization: Bearer <feed key>`. Live's old `vibe-coding.event` envelopes are accepted as they are.++- **Safer by default.** Every step is scrubbed before it is stored: API keys and tokens, passwords, private keys, and your home folder. Viewers see only what your settings allow: prompts, answers, tools, what the tools were given, files, and thinking. The delay is held on the server, so nobody sees a step early.--Persistent slot settings live in `managed_streams.broadcast_settings.vibe_coding` and are normalized to this shape:++## What Live answers now--```json--{-- "enabled": 1,-- "widget_title": "Vibe Coding",-- "viewer_depth": "standard",-- "show_prompts": 1,-- "show_responses": 1,-- "show_thinking": 0,-- "show_tool_calls": 1,-- "show_tool_arguments": 0,-- "show_file_events": 1,-- "show_file_snippets": 0,-- "redact_file_paths": 1,-- "paused": 0,-- "delay_ms": 0,-- "max_events": 18,-- "max_prompt_chars": 220,-- "max_response_chars": 360,-- "max_thinking_chars": 140,-- "max_tool_chars": 140,-- "max_snippet_chars": 140--}--```++- `wss://openvibe.live/ws/vibe-coding/publish` answers **410**, naming openvibe.chat/feeds.++- `/api/vibe-coding/*` answers **410** with `moved_to` and `api` links. For one release, a page of the previous release that asks `GET /api/vibe-coding/channel/:username/:slot/events` gets a feed that is switched off, so its widget stays hidden.++- API tokens no longer offer the `vibe_coding_publish` scope or the "GitHub Copilot Companion" preset. A feed has its own key.++- The channel chat frames Chat's card (`https://openvibe.chat/embed/feed/<feed>`). It finds the card by asking Chat which feed a slot shows: `GET https://openvibe.chat/api/chat/feeds?slot=<managed stream id>`.--Durable runtime data is stored in two PostgreSQL tables:++Live's old `vibe_coding_sessions` and `vibe_coding_events` were copied to Chat and verified (Chat's `scripts/import-live-feeds.js`), each slot keeping its newest 5,000 events. Nothing in Live reads them any more (`test/vibe-coding-moved.test.js`), and a contract migration drops them.--- `vibe_coding_sessions` — one row per `managed_stream_id + session_key`--- `vibe_coding_events` — canonical sanitized events keyed by `managed_stream_id + event_id`----## Publisher WebSocket----Streamers publish live coding activity over a dedicated WebSocket endpoint:----```text--GET /ws/vibe-coding/publish?token=hbt_xxx&managedStreamId=123&slotSlug=camp-code--```----Authentication accepts JWTs or `hbt_` API tokens. An API token needs the dedicated `vibe_coding_publish` scope (the Dashboard's **GitHub Copilot Companion** preset); the broad `stream` scope is not accepted any more (close code `4403`). The same holds for the REST settings routes under `/api/vibe-coding`.----### Server messages----Ready:----```json--{-- "type": "vibe-coding.ready",-- "ok": true,-- "managed_stream_id": 123,-- "slot_slug": "camp-code",-- "live_stream_id": 456,-- "settings": {-- "enabled": 1,-- "viewer_depth": "standard"-- },-- "timestamp": "2026-04-21T23:10:00.000Z"--}--```----Hello ack:----```json--{-- "type": "vibe-coding.hello.ack",-- "session_key": "openvibe-20260421231000-ab12cd34",-- "timestamp": "2026-04-21T23:10:00.250Z"--}--```----Event ack:----```json--{-- "type": "vibe-coding.ack",-- "event_id": "28c49b5c-9070-4fd3-b750-b67d84338f64",-- "live_stream_id": 456,-- "delivered": true,-- "timestamp": "2026-04-21T23:10:01.500Z"--}--```----Error:----```json--{-- "type": "vibe-coding.error",-- "error": "API token requires vibe_coding_publish scope"--}--```----### Client messages----Hello:----```json--{-- "type": "vibe-coding.hello",-- "protocolVersion": 1,-- "managedStreamId": 123,-- "slotSlug": "camp-code",-- "timestamp": "2026-04-21T23:10:00.000Z",-- "sessionKey": "openvibe-20260421231000-ab12cd34",-- "publisher": {-- "integrationId": "github-copilot",-- "integrationLabel": "GitHub Copilot",-- "vendor": "GitHub",-- "clientType": "vscode-extension",-- "clientName": "OpenVibe.Live Copilot Companion",-- "clientVersion": "0.0.1",-- "workspaceName": "openvibe",-- "machineName": "streamer-laptop",-- "viewerDepth": "standard",-- "capabilities": {-- "thinking": false,-- "toolCalls": true,-- "workspaceFileEvents": true-- }-- }--}--```----Event envelope:----```json--{-- "type": "vibe-coding.event",-- "protocolVersion": 1,-- "managedStreamId": 123,-- "slotSlug": "camp-code",-- "sessionKey": "openvibe-20260421231000-ab12cd34",-- "timestamp": "2026-04-21T23:10:01.100Z",-- "event": {-- "version": 1,-- "eventId": "28c49b5c-9070-4fd3-b750-b67d84338f64",-- "sessionKey": "openvibe-20260421231000-ab12cd34",-- "sessionId": "7b3e6b3a-78df-40d7-a53d-9acfa1b2cb30",-- "sequence": 17,-- "eventType": "response",-- "visibility": "public",-- "depth": "standard",-- "occurredAt": "2026-04-21T23:10:01.099Z",-- "summary": "Refactored the publish server to sanitize Copilot events before viewer fanout.",-- "source": {-- "channel": "chat-session",-- "rawType": "request.response"-- },-- "publisher": {-- "integrationId": "github-copilot",-- "integrationLabel": "GitHub Copilot",-- "vendor": "GitHub",-- "clientType": "vscode-extension",-- "clientName": "OpenVibe.Live Copilot Companion"-- },-- "response": {-- "text": "Refactored the publish server to sanitize Copilot events before viewer fanout.",-- "truncated": false-- },-- "metadata": {-- "requestId": "req-123",-- "responseId": "resp-456",-- "workspaceName": "openvibe",-- "model": "gpt-5.4"-- }-- }--}--```----Ping:----```json--{-- "type": "vibe-coding.ping",-- "protocolVersion": 1,-- "timestamp": "2026-04-21T23:10:30.000Z"--}--```----### Canonical event types----- `session.status`--- `prompt`--- `response`--- `thinking`--- `tool.call`--- `file.change`--- `file.save`----The extension publishes only canonical sanitized events. Raw debug log lines, raw transcript lines, raw chat-session lines, secrets, and absolute file paths are not broadcast directly.----`source.channel` is publisher-defined so non-Copilot integrations can describe where an event came from without changing the canonical event types.----## REST Endpoints----Public feed for viewer widgets:----```text--GET /api/vibe-coding/channel/:username/:slotIdOrSlug/events?limit=18--```----Response:----```json--{-- "managed_stream": {-- "id": 123,-- "slug": "camp-code",-- "title": "Night Bus Refactor"-- },-- "live_stream_id": 456,-- "settings": {-- "enabled": 1,-- "viewer_depth": "standard"-- },-- "publisher": {-- "integrationId": "github-copilot",-- "integrationLabel": "GitHub Copilot",-- "vendor": "GitHub",-- "clientType": "vscode-extension",-- "clientName": "OpenVibe.Live Copilot Companion",-- "clientVersion": "0.0.1",-- "workspaceName": "openvibe",-- "machineName": "streamer-laptop",-- "viewerDepth": "standard",-- "capabilities": {-- "thinking": false,-- "toolCalls": true,-- "workspaceFileEvents": true-- }-- },-- "events": []--}--```----Authenticated recent event history for the slot owner:----```text--GET /api/vibe-coding/managed/:managedStreamId/events?limit=50--```----Authenticated slot settings endpoints:----```text--GET /api/vibe-coding/managed/:managedStreamId/settings--PUT /api/vibe-coding/managed/:managedStreamId/settings--```----The workspace UI currently saves the same settings through the existing managed-stream broadcast settings save flow, which persists to `managed_streams.broadcast_settings`.----## Viewer Delivery----Viewer clients do not connect to a second public WebSocket. Live slot events are projected through the existing stream chat socket as a new message type:----```json--{-- "type": "vibe-coding",-- "managed_stream_id": 123,-- "slot_slug": "camp-code",-- "delay_ms": 0,-- "event": {-- "eventId": "28c49b5c-9070-4fd3-b750-b67d84338f64",-- "eventType": "file.change",-- "summary": "Editing server/vibe-coding/publish-server.js",-- "file": {-- "name": "publish-server.js",-- "relativePath": "server/vibe-coding/publish-server.js",-- "operation": "edit",-- "changeCount": 3,-- "snippet": null-- }-- }--}--```----The browser chat client inserts the vibe-coding widget above the slot chat header, hydrates its initial state from the REST endpoint, and can display the active publisher label so the same widget works for Copilot, Claude Code, or custom integrations.\\ No newline at end of file++The full reference is Chat's README, section "Feeds: vibe coding, live".@@@ -428,7 +428,7 @@/* ── Chat rendering (public/js/chat.js) ─────────────────────────────────────────────────────── *//* Scroll containment without a wheel handler: a chat pane never drags the page along at its ends. */--.chat-sidebar, .global-chat-main, .offline-global-chat, .chat-vibe-widget-feed, .chat-messages,++.chat-sidebar, .global-chat-main, .offline-global-chat, .chat-messages,.global-chat-messages, .fullscreen-chat-messages, .chat-users-panel, .rewards-panel, .fcw-messages, #pc-msgs {overscroll-behavior: contain;}@@@ -103,758 +103,16 @@ body::after {border-radius: 50%;}--/* ── Vibe coding widget ───────────────────────────────────── */--.chat-vibe-widget {-- display: flex;-- flex-direction: column;-- flex: 0 1 auto;-- min-height: 0;-- margin: 10px 0 12px;-- border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);-- border-radius: 12px;-- background: linear-gradient(180deg, rgba(24, 25, 31, 0.96), rgba(15, 16, 22, 0.96));-- box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);-- overflow: hidden;--}----.chat-vibe-widget-head {-- display: flex;-- align-items: center;-- justify-content: space-between;-- flex-wrap: wrap;-- gap: 12px;-- padding: 10px 12px;-- background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 12%, transparent), rgba(52, 152, 219, 0.05));-- border-bottom: 1px solid rgba(255, 255, 255, 0.06);--}----.chat-vibe-widget-actions {-- display: flex;-- align-items: center;-- justify-content: flex-end;-- flex-wrap: wrap;-- margin-left: auto;-- gap: 10px;--}----.chat-vibe-widget-title {-- display: flex;-- align-items: center;-- gap: 8px;-- font-size: 0.92rem;-- font-weight: 700;-- letter-spacing: 0.01em;--}----.chat-vibe-widget-title i {-- color: var(--accent-light);--}----.chat-vibe-widget-status {-- font-size: 0.74rem;-- text-transform: uppercase;-- letter-spacing: 0.08em;-- color: var(--text-secondary);-- transition: opacity 180ms ease, transform 180ms ease;--}----.chat-vibe-widget-status.is-hidden {-- opacity: 0;-- transform: scale(0.92);-- pointer-events: none;-- width: 0;-- min-width: 0;--}----.chat-vibe-widget-status.is-badge {-- padding: 3px 8px;-- border-radius: 999px;-- background: color-mix(in srgb, var(--accent) 16%, transparent);-- color: var(--accent-light);-- letter-spacing: 0.02em;-- font-size: 0.8rem;-- text-transform: none;--}----.chat-vibe-widget-toggle {-- border: 1px solid rgba(255, 255, 255, 0.12);-- border-radius: 999px;-- background: rgba(255, 255, 255, 0.04);-- color: var(--text-primary);-- font-size: 0.72rem;-- font-weight: 700;-- letter-spacing: 0.04em;-- padding: 6px 12px;-- cursor: pointer;-- transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 180ms ease, box-shadow 180ms ease;--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-head {-- padding: 8px 10px;--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-actions {-- gap: 8px;--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-toggle {-- padding: 5px 10px;-- font-size: 0.7rem;--}----.chat-vibe-widget-toggle:hover,--.chat-vibe-widget-toggle:focus-visible {-- background: rgba(255, 255, 255, 0.12);-- border-color: color-mix(in srgb, var(--accent) 34%, transparent);-- color: #fff;-- outline: none;-- transform: scale(1.02);-- box-shadow: 0 6px 14px rgba(0, 0, 0, 0.16);--}----.chat-vibe-widget-toggle:focus-visible {-- outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);-- outline-offset: 2px;--}----.chat-vibe-widget-head {-- transition: background 220ms ease, border-color 220ms ease;--}----.chat-vibe-widget {-- transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background 180ms ease;--}----.chat-vibe-widget-feed {-- display: flex;-- flex-direction: column;-- flex: 0 1 auto;-- gap: 8px;-- min-height: 0;-- padding: 10px 12px 12px;-- max-height: min(34vh, 300px);-- overflow-y: auto;-- overscroll-behavior: contain;-- -webkit-overflow-scrolling: touch;-- scrollbar-width: thin;-- touch-action: pan-y;-- transition: max-height 220ms ease, opacity 180ms ease, padding 180ms ease, margin 180ms ease;-- will-change: max-height, opacity;--}----.chat-vibe-new-msgs-indicator {-- display: flex;-- align-items: center;-- justify-content: center;-- gap: 6px;-- margin: 0 12px 10px;-- padding: 7px 12px;-- border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);-- border-radius: 999px;-- background: color-mix(in srgb, var(--accent) 12%, transparent);-- color: var(--accent-light);-- font-size: 0.75rem;-- font-weight: 700;-- letter-spacing: 0.03em;-- box-shadow: 0 10px 20px rgba(0, 0, 0, 0.18);-- opacity: 0;-- max-height: 0;-- overflow: hidden;-- pointer-events: none;-- transition: opacity 180ms ease, max-height 220ms ease, margin 220ms ease, padding 220ms ease, transform 180ms ease;-- transform: translateY(6px);--}----.chat-vibe-new-msgs-indicator.is-visible {-- opacity: 1;-- max-height: 88px;-- margin: 8px 12px 10px;-- padding: 7px 12px;-- pointer-events: auto;-- transform: translateY(0);--}----.chat-vibe-new-msgs-indicator.is-hidden {-- opacity: 0;-- max-height: 0;-- margin: 0 12px 0;-- padding-top: 0;-- padding-bottom: 0;-- pointer-events: none;-- transform: translateY(6px);--}----.chat-vibe-new-msgs-indicator:hover,--.chat-vibe-new-msgs-indicator:focus-visible {-- background: color-mix(in srgb, var(--accent) 18%, transparent);-- border-color: color-mix(in srgb, var(--accent) 38%, transparent);-- color: #fff;-- outline: none;--}----.chat-vibe-widget-feed::-webkit-scrollbar {-- width: 8px;--}----.chat-vibe-widget-feed::-webkit-scrollbar-thumb {-- background: rgba(255, 255, 255, 0.14);-- border-radius: 999px;--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-feed {-- max-height: 0;-- opacity: 0;-- padding-top: 0;-- padding-bottom: 0;-- margin: 0 12px 0;-- pointer-events: none;-- overflow: hidden;--}----.chat-vibe-widget.is-collapsed {-- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-head {-- border-bottom-color: rgba(255, 255, 255, 0.08);-- background: rgba(255, 255, 255, 0.04);--}----.chat-vibe-widget.is-collapsed .chat-vibe-widget-toggle {-- background: rgba(255, 255, 255, 0.06);--}----.chat-vibe-widget-empty {-- font-size: 0.82rem;-- color: var(--text-secondary);--}----.chat-vibe-item {-- display: flex;-- flex-direction: column;-- gap: 8px;-- padding: 10px 11px;-- border-radius: 10px;-- background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));-- border: 1px solid rgba(255, 255, 255, 0.06);--}----.chat-vibe-item-top {-- display: flex;-- flex-direction: column;-- align-items: stretch;-- gap: 6px;--}----.chat-vibe-item-heading {-- display: flex;-- align-items: center;-- justify-content: space-between;-- gap: 10px;--}----.chat-vibe-item-label {-- font-size: 0.69rem;-- font-weight: 700;-- text-transform: uppercase;-- letter-spacing: 0.08em;-- color: var(--accent-light);--}----.chat-vibe-item-meta {-- display: flex;-- flex-wrap: wrap;-- gap: 6px;--}----.chat-vibe-item-meta-chip {-- display: inline-flex;-- align-items: center;-- gap: 4px;-- padding: 2px 7px;-- border-radius: 999px;-- background: rgba(255, 255, 255, 0.05);-- border: 1px solid rgba(255, 255, 255, 0.05);-- font-size: 0.7rem;-- color: var(--text-secondary);--}----.chat-vibe-item-time {-- font-size: 0.71rem;-- color: var(--text-muted);-- white-space: nowrap;--}----.chat-vibe-item-text {-- font-size: 0.84rem;-- color: var(--text-primary);-- line-height: 1.45;-- word-break: break-word;-- white-space: pre-wrap;-- padding: 10px 12px;-- border-radius: 11px;-- background: rgba(0, 0, 0, 0.16);-- border-left: 3px solid rgba(255, 255, 255, 0.08);-- transition: background 180ms ease, border-color 180ms ease, transform 180ms ease;--}----.chat-vibe-item-text:hover {-- background: rgba(255, 255, 255, 0.05);-- border-left-color: rgba(255, 255, 255, 0.15);-- transform: translateY(-1px);--}----.chat-vibe-item-details {-- display: grid;-- gap: 6px;--}----.chat-vibe-item-detail {-- display: flex;-- align-items: flex-start;-- gap: 8px;--}----.chat-vibe-item-detail-label {-- min-width: 72px;-- padding-top: 1px;-- font-size: 0.66rem;-- font-weight: 700;-- text-transform: uppercase;-- letter-spacing: 0.08em;-- color: var(--text-muted);--}----.chat-vibe-item-detail-value {-- flex: 1;-- min-width: 0;-- font-size: 0.77rem;-- line-height: 1.45;-- color: var(--text-secondary);-- word-break: break-word;-- white-space: pre-wrap;--}----.chat-vibe-item-detail-value.is-code {-- padding: 7px 8px;-- border-radius: 8px;-- background: rgba(0, 0, 0, 0.16);-- border: 1px solid rgba(255, 255, 255, 0.05);-- font-family: var(--font-mono);-- font-size: 0.73rem;-- color: var(--text-primary);--}----.chat-vibe-item-prompt .chat-vibe-item-label {-- color: #8fd0ff;--}----.chat-vibe-item-response .chat-vibe-item-label {-- color: #9fe3b5;--}----.chat-vibe-item-response .chat-vibe-item-text {-- border-left-color: rgba(159, 227, 181, 0.4);--}----.chat-vibe-item-thinking .chat-vibe-item-label {-- color: #f0c27a;--}----.chat-vibe-item-thinking .chat-vibe-item-text {-- border-left-color: rgba(240, 194, 122, 0.45);--}----.chat-vibe-item-tool-call .chat-vibe-item-label,--.chat-vibe-item-tool .chat-vibe-item-label {-- color: #d4a5ff;--}----.chat-vibe-item-tool-call .chat-vibe-item-text,--.chat-vibe-item-tool .chat-vibe-item-text {-- border-left-color: rgba(212, 165, 255, 0.42);--}----.chat-vibe-item-file-change .chat-vibe-item-label,--.chat-vibe-item-file-save .chat-vibe-item-label {-- color: #ffb37a;--}----.chat-vibe-item-file-change .chat-vibe-item-text,--.chat-vibe-item-file-save .chat-vibe-item-text {-- border-left-color: rgba(255, 179, 122, 0.45);--}----.chat-vibe-item-prompt .chat-vibe-item-text {-- border-left-color: rgba(143, 208, 255, 0.42);--}----.chat-vibe-system {-- display: flex;-- align-items: center;-- gap: 8px;-- padding: 0 2px;-- font-size: 0.73rem;-- color: var(--text-muted);--}----.chat-vibe-system-time {-- flex-shrink: 0;-- color: var(--text-muted);--}----.chat-vibe-system-text {-- color: var(--text-secondary);--}----.chat-vibe-turn {-- display: flex;-- flex-direction: column;-- gap: 6px;--}----.chat-vibe-turn.is-compact {-- gap: 4px;--}----.chat-vibe-turn-prompt-row {-- display: flex;-- justify-content: flex-end;--}----.chat-vibe-turn-prompt {-- max-width: min(88%, 520px);-- padding: 7px 11px;-- border-radius: 14px 14px 4px 14px;-- background: rgba(143, 208, 255, 0.12);-- border: 1px solid rgba(143, 208, 255, 0.2);-- color: var(--text-primary);-- font-size: 0.81rem;-- line-height: 1.4;-- word-break: break-word;--}----.chat-vibe-turn-response {-- position: relative;-- padding: 10px 11px;-- border-radius: 12px;-- background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.02));-- border: 1px solid rgba(255, 255, 255, 0.07);-- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);--}----.chat-vibe-turn.is-compact .chat-vibe-turn-response {-- padding: 8px 10px;-- border-radius: 10px;-- background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015));--}----.chat-vibe-turn.is-update .chat-vibe-turn-response {-- border-color: rgba(255, 255, 255, 0.05);--}----.chat-vibe-turn-response-top {-- position: absolute;-- top: 10px;-- right: 11px;-- display: flex;-- align-items: center;-- justify-content: flex-end;-- pointer-events: none;--}----.chat-vibe-turn.is-compact .chat-vibe-turn-response-top {-- top: 8px;-- right: 10px;--}----.chat-vibe-turn-time {-- font-size: 0.68rem;-- color: var(--text-muted);-- padding: 2px 6px;-- border-radius: 999px;-- background: rgba(0, 0, 0, 0.18);-- border: 1px solid rgba(255, 255, 255, 0.05);--}----.chat-vibe-turn-body {-- display: flex;-- flex-direction: column;-- gap: 7px;-- padding-right: 68px;--}----.chat-vibe-turn.is-compact .chat-vibe-turn-body {-- gap: 5px;-- padding-right: 62px;--}----.chat-vibe-turn-paragraph {-- margin: 0;-- font-size: 0.84rem;-- line-height: 1.46;-- color: var(--text-primary);-- word-break: break-word;-- white-space: pre-wrap;--}----.chat-vibe-turn.is-compact .chat-vibe-turn-paragraph {-- font-size: 0.79rem;-- line-height: 1.42;--}----.chat-vibe-turn.is-update .chat-vibe-turn-paragraph {-- color: var(--text-secondary);--}----.chat-vibe-turn-paragraph.is-muted {-- font-size: 0.78rem;-- color: var(--text-secondary);--}----.chat-vibe-turn-bullets {-- margin: 2px 0 0;-- padding-left: 18px;-- display: grid;-- gap: 5px;--}----.chat-vibe-turn-list-item {-- font-size: 0.8rem;-- line-height: 1.45;-- color: var(--text-primary);--}----.chat-vibe-turn-list-item.is-muted {-- font-size: 0.77rem;-- color: var(--text-secondary);--}----.chat-vibe-turn-disclosure {-- margin-top: 7px;-- border: 1px solid rgba(255, 255, 255, 0.06);-- border-radius: 10px;-- background: rgba(255, 255, 255, 0.03);-- overflow: hidden;--}----.chat-vibe-turn-disclosure summary {-- display: grid;-- grid-template-columns: 18px auto minmax(0, 1fr) auto;-- align-items: center;-- column-gap: 8px;-- row-gap: 2px;-- padding: 8px 10px;-- cursor: pointer;-- list-style: none;-- font-size: 0.75rem;-- font-weight: 600;-- color: var(--text-secondary);-- position: relative;--}----.chat-vibe-turn-disclosure-kicker {-- grid-column: 2 / 3;-- white-space: nowrap;-- overflow: hidden;-- text-overflow: ellipsis;--}----.chat-vibe-turn-disclosure-title {-- grid-column: 3 / 4;-- min-width: 0;-- overflow: hidden;-- text-overflow: ellipsis;-- white-space: nowrap;--}----.chat-vibe-turn-disclosure-meta {-- grid-column: 4 / 5;-- white-space: nowrap;-- flex-shrink: 0;--}----.chat-vibe-turn-disclosure summary::-webkit-details-marker {-- display: none;--}----.chat-vibe-turn-disclosure summary::before {-- content: '›';-- display: inline-flex;-- align-items: center;-- justify-content: center;-- width: 18px;-- height: 18px;-- margin-right: 4px;-- font-size: 0.95rem;-- line-height: 1;-- color: var(--text-muted);-- transition: transform 0.16s ease;-- transform-origin: center;--}----.chat-vibe-turn-disclosure[open] summary::before {-- transform: rotate(90deg);--}----.chat-vibe-turn-disclosure summary:hover::before,--.chat-vibe-turn-disclosure summary:focus-visible::before {-- color: var(--text-primary);--}----.chat-vibe-turn-disclosure-body {-- display: grid;-- gap: 8px;-- padding: 0 10px 10px;--}----.chat-vibe-turn-disclosure-kicker {-- display: inline-flex;-- align-items: center;-- padding: 2px 7px;-- border-radius: 999px;-- background: rgba(255, 255, 255, 0.04);-- border: 1px solid rgba(255, 255, 255, 0.06);-- font-size: 0.64rem;-- font-weight: 700;-- letter-spacing: 0.07em;-- text-transform: uppercase;-- color: var(--text-secondary);--}----.chat-vibe-turn-disclosure-title {-- min-width: 0;-- overflow: hidden;-- text-overflow: ellipsis;-- white-space: nowrap;-- color: var(--text-primary);--}----.chat-vibe-turn-disclosure-meta {-- font-size: 0.67rem;-- color: var(--text-muted);-- white-space: nowrap;--}----.chat-vibe-turn-disclosure-progress .chat-vibe-turn-disclosure-kicker {-- color: #8fd0ff;--}----.chat-vibe-turn-disclosure-thinking .chat-vibe-turn-disclosure-kicker {-- color: #f0c27a;--}----.chat-vibe-turn-disclosure-tools .chat-vibe-turn-disclosure-kicker {-- color: #d4a5ff;--}----.chat-vibe-turn-disclosure-commands .chat-vibe-turn-disclosure-kicker {-- color: #8fd0ff;--}----.chat-vibe-turn-disclosure-files .chat-vibe-turn-disclosure-kicker {-- color: #9fe3b5;--}----.chat-vibe-turn-note {-- margin: 0;-- padding: 7px 8px;-- border-radius: 8px;-- background: rgba(0, 0, 0, 0.16);-- border: 1px solid rgba(255, 255, 255, 0.05);-- font-size: 0.77rem;-- line-height: 1.45;-- color: var(--text-secondary);-- white-space: pre-wrap;--}----.chat-vibe-turn-note-mono {-- font-family: var(--font-mono);-- font-size: 0.72rem;-- color: var(--text-primary);--}----.chat-vibe-turn-note-mono.is-command {-- background: rgba(143, 208, 255, 0.08);-- border-color: rgba(143, 208, 255, 0.16);--}----.chat-vibe-turn-list-row {-- display: flex;-- align-items: flex-start;-- justify-content: space-between;-- gap: 10px;--}----.chat-vibe-turn-list-main {-- min-width: 0;-- display: flex;-- flex-direction: column;-- gap: 3px;--}----.chat-vibe-turn-list-title {-- font-size: 0.78rem;-- font-weight: 700;-- color: var(--text-primary);-- word-break: break-word;--}----.chat-vibe-turn-list-title.is-mono {-- font-family: var(--font-mono);-- font-size: 0.73rem;--}----.chat-vibe-turn-list-subtitle {-- font-size: 0.71rem;-- line-height: 1.4;-- color: var(--text-secondary);-- word-break: break-word;--}----.chat-vibe-turn-pill-group {-- display: flex;-- flex-wrap: wrap;-- justify-content: flex-end;-- gap: 6px;-- flex-shrink: 0;--}----.chat-vibe-turn-pill {-- display: inline-flex;-- align-items: center;-- padding: 3px 7px;-- border-radius: 999px;-- border: 1px solid rgba(255, 255, 255, 0.09);-- background: rgba(255, 255, 255, 0.04);-- color: var(--text-secondary);-- font-size: 0.68rem;-- font-weight: 700;-- line-height: 1.2;--}----.chat-vibe-turn-pill.is-count {-- color: #d9c6ff;-- background: rgba(212, 165, 255, 0.12);-- border-color: rgba(212, 165, 255, 0.18);--}----.chat-vibe-turn-pill.is-edit {-- color: #9fe3b5;-- background: rgba(77, 184, 129, 0.14);-- border-color: rgba(77, 184, 129, 0.2);--}----.chat-vibe-turn-pill.is-save {-- color: #8fd0ff;-- background: rgba(143, 208, 255, 0.12);-- border-color: rgba(143, 208, 255, 0.18);++/* ── The feed card (public/js/chat.js refreshFeedCard): OpenVibe.Chat's embed in a frame ── */++.chat-feed-card {++ display: block;++ width: 100%;++ height: 42px;++ margin: 8px 0 10px;++ border: 0;++ background: transparent;}--.chat-vibe-turn-file-card {-- display: grid;-- gap: 7px;--}/* Both glows stay inside the page's width — an absolutely positioned box past the right edgewidens the document and scrolls it sideways. The gradient centre sits on the corner instead. */body::before {@@@ -4868,11 +4126,8 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .bborder-radius: 16px 16px 0 0;box-shadow: 0 -8px 32px rgba(0,0,0,0.45);}-- .chat-vibe-widget {-- margin: 8px 0 10px;-- }-- .chat-vibe-widget-feed {-- max-height: min(24dvh, 200px);++ .chat-feed-card {++ margin: 6px 0 8px;}.chat-sidebar.mobile-chat-open {transform: translateY(0);@@@ -8462,9 +7717,6 @@ body.theme-light .btn-outline:hover {}body.mobile-chat-visible .mobile-chat-toggle { display: none !important; }body.mobile-chat-visible #messenger-toggle { display: none !important; }-- .chat-vibe-widget-feed {-- max-height: 240px;-- }.video-container { max-height: 50vh; }.mobile-chat-toggle { display: flex; }/* The chat is an overlay at ≤1180px — give its header a working close button. */@@@ -37,98 +37,6 @@ let _wsState = {_liveStatsTimer: null, // interval for updating live stats};--const WS_VIBE_DEFAULTS = {-- enabled: 0,-- widget_title: 'Vibe Coding',-- viewer_depth: 'standard',-- show_prompts: 1,-- show_responses: 1,-- show_thinking: 0,-- show_tool_calls: 1,-- show_tool_arguments: 0,-- show_file_events: 1,-- show_file_snippets: 0,-- redact_file_paths: 1,-- paused: 0,-- delay_ms: 0,-- max_events: 18,-- max_prompt_chars: 220,-- max_response_chars: 360,-- max_thinking_chars: 140,-- max_tool_chars: 140,-- max_snippet_chars: 140,--};----function _wsNormalizeVibeSettings(settings) {-- const source = settings && typeof settings === 'object' ? settings : {};-- const boolField = (key, fallback) => {-- if (source[key] === undefined || source[key] === null) return fallback;-- return source[key] ? 1 : 0;-- };-- const intField = (key, fallback, min, max) => {-- const value = parseInt(source[key], 10);-- if (!Number.isFinite(value)) return fallback;-- return Math.min(max, Math.max(min, value));-- };-- const depth = ['headline', 'standard', 'deep'].includes(source.viewer_depth) ? source.viewer_depth : WS_VIBE_DEFAULTS.viewer_depth;-- return {-- enabled: boolField('enabled', WS_VIBE_DEFAULTS.enabled),-- widget_title: String(source.widget_title || WS_VIBE_DEFAULTS.widget_title).trim() || WS_VIBE_DEFAULTS.widget_title,-- viewer_depth: depth,-- show_prompts: boolField('show_prompts', WS_VIBE_DEFAULTS.show_prompts),-- show_responses: boolField('show_responses', WS_VIBE_DEFAULTS.show_responses),-- show_thinking: boolField('show_thinking', WS_VIBE_DEFAULTS.show_thinking),-- show_tool_calls: boolField('show_tool_calls', WS_VIBE_DEFAULTS.show_tool_calls),-- show_tool_arguments: boolField('show_tool_arguments', WS_VIBE_DEFAULTS.show_tool_arguments),-- show_file_events: boolField('show_file_events', WS_VIBE_DEFAULTS.show_file_events),-- show_file_snippets: boolField('show_file_snippets', WS_VIBE_DEFAULTS.show_file_snippets),-- redact_file_paths: (source.redact_file_paths === 0 || source.redact_file_paths === false) ? 0 : 1,-- paused: boolField('paused', WS_VIBE_DEFAULTS.paused),-- delay_ms: intField('delay_ms', WS_VIBE_DEFAULTS.delay_ms, 0, 120000),-- max_events: intField('max_events', WS_VIBE_DEFAULTS.max_events, 1, 100),-- max_prompt_chars: intField('max_prompt_chars', WS_VIBE_DEFAULTS.max_prompt_chars, 60, 1200),-- max_response_chars: intField('max_response_chars', WS_VIBE_DEFAULTS.max_response_chars, 80, 2400),-- max_thinking_chars: intField('max_thinking_chars', WS_VIBE_DEFAULTS.max_thinking_chars, 40, 1200),-- max_tool_chars: intField('max_tool_chars', WS_VIBE_DEFAULTS.max_tool_chars, 40, 1200),-- max_snippet_chars: intField('max_snippet_chars', WS_VIBE_DEFAULTS.max_snippet_chars, 40, 1200),-- };--}----function _wsReadStoredVibeSettings(profile) {-- if (!profile || typeof profile !== 'object') return null;-- return profile.vibeCoding || profile.vibe_coding || null;--}----function _wsGetVibeSettings() {-- _wsState.profile = _wsState.profile || {};-- _wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));-- return _wsState.profile.vibeCoding;--}----function _wsCollectVibeSettingsFromDom() {-- return _wsNormalizeVibeSettings({-- enabled: document.getElementById('bc-ws-vibe-enabled')?.checked ? 1 : 0,-- widget_title: document.getElementById('bc-ws-vibe-title')?.value.trim() || WS_VIBE_DEFAULTS.widget_title,-- viewer_depth: document.getElementById('bc-ws-vibe-depth')?.value || WS_VIBE_DEFAULTS.viewer_depth,-- show_prompts: document.getElementById('bc-ws-vibe-prompts')?.checked ? 1 : 0,-- show_responses: document.getElementById('bc-ws-vibe-responses')?.checked ? 1 : 0,-- show_thinking: document.getElementById('bc-ws-vibe-thinking')?.checked ? 1 : 0,-- show_tool_calls: document.getElementById('bc-ws-vibe-tools')?.checked ? 1 : 0,-- show_tool_arguments: document.getElementById('bc-ws-vibe-tool-args')?.checked ? 1 : 0,-- show_file_events: document.getElementById('bc-ws-vibe-files')?.checked ? 1 : 0,-- show_file_snippets: document.getElementById('bc-ws-vibe-snippets')?.checked ? 1 : 0,-- redact_file_paths: document.getElementById('bc-ws-vibe-redact-paths')?.checked ? 1 : 0,-- paused: document.getElementById('bc-ws-vibe-paused')?.checked ? 1 : 0,-- delay_ms: document.getElementById('bc-ws-vibe-delay')?.value || WS_VIBE_DEFAULTS.delay_ms,-- max_events: document.getElementById('bc-ws-vibe-max-events')?.value || WS_VIBE_DEFAULTS.max_events,-- max_prompt_chars: document.getElementById('bc-ws-vibe-max-prompt')?.value || WS_VIBE_DEFAULTS.max_prompt_chars,-- max_response_chars: document.getElementById('bc-ws-vibe-max-response')?.value || WS_VIBE_DEFAULTS.max_response_chars,-- max_thinking_chars: document.getElementById('bc-ws-vibe-max-thinking')?.value || WS_VIBE_DEFAULTS.max_thinking_chars,-- max_tool_chars: document.getElementById('bc-ws-vibe-max-tool')?.value || WS_VIBE_DEFAULTS.max_tool_chars,-- max_snippet_chars: document.getElementById('bc-ws-vibe-max-snippet')?.value || WS_VIBE_DEFAULTS.max_snippet_chars,-- });--}--/* ── Init ────────────────────────────────────────────────────── */// PowerChat relay controls only make sense for streamers with a connected PowerChat@@@ -264,7 +172,6 @@ async function _wsLoadProfile(managedStreamId) {// OpenRestream-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once)._wsState.streamKeyHint = data.stream_key_hint || null;_wsState.profile = data.broadcast_settings || {};-- _wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));_wsState.whipUrlBase = data.whip_url_base || null;_wsState.whipUrlSource = data.whip_url_source || null;_wsState.whipUrlWarning = data.whip_url_warning || null;@@@ -304,13 +211,9 @@ function _wsRenderPanel() {panel.style.display = '';const p = _wsState.profile;-- const vibe = _wsGetVibeSettings();const isLive = _wsIsManagedStreamLive(ms.id);const method = ms.streaming_method || 'browser';const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';-- const vibePublisherUrl = _wsGetVibePublisherUrl();-- const vibeFeedUrl = _wsGetVibeFeedUrl(ms);-- const vibeSlotRef = ms.slug || ms.id;// Class C: find active sessionlet liveSessionId = null;@@@ -822,143 +725,9 @@ function _wsRenderPanel() {<summary><i class="fa-solid fa-code"></i> Vibe Coding Feed</summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">-- Show a live coding activity widget above this slot's chat room. Any compatible publisher can send a sanitized coding feed into this slot, and viewers only see the fields enabled here.-- </p>-- <div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">-- <div style="font-weight:600;margin-bottom:8px"><i class="fa-solid fa-plug-circle-bolt"></i> VS Code Quick Setup</div>-- <ol style="margin:0 0 10px 18px;font-size:0.82rem;color:var(--text-secondary);display:flex;flex-direction:column;gap:6px">-- <li>Create an API token on the Dashboard using the <strong>GitHub Copilot Companion</strong> preset. That preset includes the <code>vibe_coding_publish</code> scope.</li>-- <li>In VS Code, run <strong>OpenVibe.Live Copilot Companion: Open Settings</strong>.</li>-- <li>Paste the publisher URL below into <code>copilotChatWebSocket.url</code>.</li>-- <li>Paste this slot ID into <code>copilotChatWebSocket.managedStreamId</code> and optionally the slot slug into <code>copilotChatWebSocket.slotSlug</code>.</li>-- <li>Paste the API token into <code>copilotChatWebSocket.apiToken</code>, then run <strong>Connect</strong>.</li>-- </ol>-- <div style="display:flex;flex-wrap:wrap;gap:8px">-- <button class="btn btn-small btn-outline" onclick="_wsCopyVibeSettingsTemplate()"><i class="fa-solid fa-copy"></i> Copy Settings Template</button>-- <button class="btn btn-small btn-outline" onclick="navigate('/dashboard')"><i class="fa-solid fa-key"></i> Open Dashboard Tokens</button>-- </div>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:2">-- <label>Publisher WebSocket URL</label>-- <div style="display:flex;gap:6px;align-items:center">-- <input type="text" id="bc-ws-vibe-publisher-url" class="form-input form-input-sm" readonly value="${esc(vibePublisherUrl)}">-- <button class="btn btn-small" onclick="_wsCopyVibePublisherUrl()" title="Copy URL"><i class="fa-solid fa-copy"></i></button>-- </div>-- </div>-- <div class="form-group" style="flex:1">-- <label>Managed Stream ID</label>-- <div style="display:flex;gap:6px;align-items:center">-- <input type="text" id="bc-ws-vibe-managed-stream-id" class="form-input form-input-sm" readonly value="${ms.id}">-- <button class="btn btn-small" onclick="_wsCopyVibeManagedStreamId()" title="Copy ID"><i class="fa-solid fa-copy"></i></button>-- </div>-- </div>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:1">-- <label>Slot Slug</label>-- <div style="display:flex;gap:6px;align-items:center">-- <input type="text" id="bc-ws-vibe-slot-slug" class="form-input form-input-sm" readonly value="${esc(ms.slug || '')}" placeholder="Optional">-- <button class="btn btn-small" onclick="_wsCopyVibeSlotSlug()" title="Copy slug"><i class="fa-solid fa-copy"></i></button>-- </div>-- </div>-- <div class="form-group" style="flex:2">-- <label>Public Feed Endpoint</label>-- <div style="display:flex;gap:6px;align-items:center">-- <input type="text" id="bc-ws-vibe-feed-url" class="form-input form-input-sm" readonly value="${esc(vibeFeedUrl)}">-- <button class="btn btn-small" onclick="_wsCopyVibeFeedUrl()" title="Copy feed URL"><i class="fa-solid fa-copy"></i></button>-- </div>-- </div>-- </div>-- <div class="form-group">-- <label class="bc-toggle-label">-- <input type="checkbox" id="bc-ws-vibe-enabled" ${vibe.enabled ? 'checked' : ''} onchange="_wsSlotSettingChanged()">-- Enable vibe-coding widget for this slot-- </label>-- </div>-- <div class="form-group">-- <label class="bc-toggle-label">-- <input type="checkbox" id="bc-ws-vibe-paused" ${vibe.paused ? 'checked' : ''} onchange="_wsSlotSettingChanged()">-- Pause viewer updates without disconnecting the publisher-- </label>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:2">-- <label>Widget Title</label>-- <input type="text" id="bc-ws-vibe-title" class="form-input form-input-sm"-- value="${esc(vibe.widget_title || 'Vibe Coding')}" maxlength="80"-- oninput="_wsSlotSettingChanged()">-- </div>-- <div class="form-group" style="flex:1">-- <label>Viewer Depth</label>-- <select id="bc-ws-vibe-depth" class="form-input" onchange="_wsSlotSettingChanged()">-- <option value="headline" ${vibe.viewer_depth === 'headline' ? 'selected' : ''}>Headline</option>-- <option value="standard" ${vibe.viewer_depth === 'standard' ? 'selected' : ''}>Standard</option>-- <option value="deep" ${vibe.viewer_depth === 'deep' ? 'selected' : ''}>Deep</option>-- </select>-- </div>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:1">-- <label>Delay (ms)</label>-- <input type="number" id="bc-ws-vibe-delay" class="form-input form-input-sm"-- value="${vibe.delay_ms || 0}" min="0" max="120000"-- oninput="_wsSlotSettingChanged()">-- </div>-- <div class="form-group" style="flex:1">-- <label>Max Events</label>-- <input type="number" id="bc-ws-vibe-max-events" class="form-input form-input-sm"-- value="${vibe.max_events || 18}" min="1" max="100"-- oninput="_wsSlotSettingChanged()">-- </div>-- </div>-- <div class="bc-ws-row" style="flex-wrap:wrap">-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-prompts" ${vibe.show_prompts ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Prompts</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-responses" ${vibe.show_responses ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Responses</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-thinking" ${vibe.show_thinking ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Thinking summaries</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-tools" ${vibe.show_tool_calls ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Tool calls</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-tool-args" ${vibe.show_tool_arguments ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Tool argument previews</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-files" ${vibe.show_file_events ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> File events</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-snippets" ${vibe.show_file_snippets ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> File snippets</label>-- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-redact-paths" ${vibe.redact_file_paths ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Redact file paths</label>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:1">-- <label>Prompt Chars</label>-- <input type="number" id="bc-ws-vibe-max-prompt" class="form-input form-input-sm"-- value="${vibe.max_prompt_chars || 220}" min="60" max="1200"-- oninput="_wsSlotSettingChanged()">-- </div>-- <div class="form-group" style="flex:1">-- <label>Response Chars</label>-- <input type="number" id="bc-ws-vibe-max-response" class="form-input form-input-sm"-- value="${vibe.max_response_chars || 360}" min="80" max="2400"-- oninput="_wsSlotSettingChanged()">-- </div>-- <div class="form-group" style="flex:1">-- <label>Thinking Chars</label>-- <input type="number" id="bc-ws-vibe-max-thinking" class="form-input form-input-sm"-- value="${vibe.max_thinking_chars || 140}" min="40" max="1200"-- oninput="_wsSlotSettingChanged()">-- </div>-- </div>-- <div class="bc-ws-row">-- <div class="form-group" style="flex:1">-- <label>Tool Chars</label>-- <input type="number" id="bc-ws-vibe-max-tool" class="form-input form-input-sm"-- value="${vibe.max_tool_chars || 140}" min="40" max="1200"-- oninput="_wsSlotSettingChanged()">-- </div>-- <div class="form-group" style="flex:1">-- <label>Snippet Chars</label>-- <input type="number" id="bc-ws-vibe-max-snippet" class="form-input form-input-sm"-- value="${vibe.max_snippet_chars || 140}" min="40" max="1200"-- oninput="_wsSlotSettingChanged()">-- </div>-- </div>-- <p class="muted" style="font-size:0.78rem;margin:8px 0 0">-- This slot listens on <code>${esc(vibeSlotRef)}</code>. The same publish/feed endpoints also work for other coding publishers, not just the Copilot reference extension.++ Show what you are building while you stream: Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and viewers follow along in a card above this slot's chat. Feeds live on OpenVibe Chat: make one there, pick this slot, and paste its Claude Code hooks. Secrets are wiped before anyone sees a step.</p>++ <a class="btn btn-small btn-outline" href="https://openvibe.chat/feeds" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square"></i> Set up a feed on openvibe.chat</a></div></details>@@@ -1954,7 +1723,6 @@ async function _wsSaveAll() {screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,-- vibeCoding: _wsCollectVibeSettingsFromDom(),};// Save structured fields@@@ -2080,7 +1848,6 @@ function _wsSlotSettingChanged() {ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;-- _wsState.profile.vibeCoding = _wsCollectVibeSettingsFromDom();_wsMarkDirty();}@@@ -3292,63 +3059,6 @@ function _wsCopyOverlayUrl() {.catch(() => { prompt('Copy manually:', input.value); });}--function _wsGetVibePublisherUrl() {-- const origin = String(window.location.origin || '').replace(/\/$/, '');-- if (!origin) return '/ws/vibe-coding/publish';-- return origin.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/vibe-coding/publish';--}----function _wsGetVibeFeedUrl(ms = _wsState.selectedMs) {-- if (!ms || !currentUser?.username) return '';-- return `${window.location.origin.replace(/\/$/, '')}/api/vibe-coding/channel/${encodeURIComponent(currentUser.username)}/${encodeURIComponent(String(ms.slug || ms.id))}/events`;--}----function _wsCopyVibePublisherUrl() {-- _wsCopyText(_wsGetVibePublisherUrl(), 'Vibe publisher URL copied');--}----function _wsCopyVibeManagedStreamId() {-- if (!_wsState.selectedMs?.id) {-- toast('No managed stream selected', 'error');-- return;-- }-- _wsCopyText(String(_wsState.selectedMs.id), 'Managed stream ID copied');--}----function _wsCopyVibeSlotSlug() {-- if (!_wsState.selectedMs?.slug) {-- toast('This slot does not have a slug yet', 'error');-- return;-- }-- _wsCopyText(String(_wsState.selectedMs.slug), 'Slot slug copied');--}----function _wsCopyVibeFeedUrl() {-- const url = _wsGetVibeFeedUrl();-- if (!url) {-- toast('Public feed URL unavailable', 'error');-- return;-- }-- _wsCopyText(url, 'Public feed URL copied');--}----function _wsCopyVibeSettingsTemplate() {-- if (!_wsState.selectedMs?.id) {-- toast('No managed stream selected', 'error');-- return;-- }-- const template = {-- copilotChatWebSocket: {-- url: _wsGetVibePublisherUrl(),-- apiToken: 'PASTE_VIBE_CODING_TOKEN_HERE',-- managedStreamId: _wsState.selectedMs.id,-- slotSlug: _wsState.selectedMs.slug || '',-- autoConnect: true,-- },-- };-- _wsCopyText(JSON.stringify(template, null, 2), 'Extension settings template copied');--}--function _wsPreviewOverlay() {const input = document.getElementById('bc-ws-overlay-url');if (!input) return;@@@ -60,48 +60,6 @@ let _globalFeedReconnectTimer = null;let _globalFeedReconnectDelay = 3000;const _GLOBAL_FEED_RECONNECT_MAX = 20000;--// ── Vibe coding widget ──────────────────────────────────────--function _loadVibeWidgetCollapsedPreference() {-- try {-- return localStorage.getItem('openvibe_vibe_widget_collapsed') === '1';-- } catch {-- return false;-- }--}----function _saveVibeWidgetCollapsedPreference(collapsed) {-- try {-- if (collapsed) {-- localStorage.setItem('openvibe_vibe_widget_collapsed', '1');-- return;-- }-- localStorage.removeItem('openvibe_vibe_widget_collapsed');-- } catch {-- // Ignore storage failures so the widget still works in restricted contexts.-- }--}----let _vibeWidgetState = {-- key: null,-- panel: null,-- feed: null,-- indicator: null,-- settings: null,-- publisher: null,-- events: [],-- timers: [],-- collapsed: _loadVibeWidgetCollapsedPreference(),-- userScrolledUp: false,-- unreadCount: 0,-- refreshTimer: null,-- syncState: 'idle',-- lastSyncedAt: 0,-- openDisclosures: new Set(),--};--const VIBE_WIDGET_FALLBACK_MAX = 18;--const VIBE_WIDGET_REFRESH_MS = 5000;--const VIBE_WIDGET_SCROLL_THRESHOLD = 56;--// ── Slow mode state ─────────────────────────────────────────let chatSlowModeSeconds = 0;let chatSlowModeCooldownTimer = null;@@@ -994,7 +952,14 @@ function getChatRenderTargetId() {return getChatEl().messages?.id || null;}--function _getVibeWidgetContext() {++// ── The feed card: what the streamer is building, live ─────++// Vibe coding feeds live on OpenVibe.Chat (its server/feeds/, openvibe.chat/feeds): a streamer shows one on a channel++// slot, and Chat answers which feed a slot shows. Its card is Chat's own embed, framed above the chat; it folds itself++// and tells this page its height. Chat also sends the slot's chat the steps as `vibe-coding` frames (not needed here).++const FEED_ORIGIN = 'https://openvibe.chat';++const _feedCard = { key: null, frame: null, seq: 0 };++++function _feedCardContext() {let streamData = currentStreamData;if ((!streamData || streamData.id !== chatStreamId) && typeof broadcastState !== 'undefined' && broadcastState?.streams && chatStreamId) {const stateEntry = broadcastState.streams instanceof Map@@@ -1004,1100 +969,53 @@ function _getVibeWidgetContext() {streamData = stateEntry.streamData;}}-- const username = streamData?.username || currentUser?.username || null;-- const slotRef = streamData?.managed_stream_slug || streamData?.managed_stream_id || null;const managedStreamId = streamData?.managed_stream_id || null;-- if (!username || !slotRef || !managedStreamId) return null;-- return { username, slotRef, managedStreamId };--}----function _clearVibeWidgetTimers() {-- (_vibeWidgetState.timers || []).forEach((timerId) => clearTimeout(timerId));-- _vibeWidgetState.timers = [];--}----function _clearVibeWidgetRefreshTimer() {-- if (_vibeWidgetState.refreshTimer) {-- clearInterval(_vibeWidgetState.refreshTimer);-- _vibeWidgetState.refreshTimer = null;-- }--}----function _setVibeWidgetCollapsed(collapsed) {-- _vibeWidgetState.collapsed = !!collapsed;-- _saveVibeWidgetCollapsedPreference(_vibeWidgetState.collapsed);-- if (_vibeWidgetState.panel) {-- _renderVibeWidget();-- }--}----function _toggleVibeWidgetCollapsed() {-- _setVibeWidgetCollapsed(!_vibeWidgetState.collapsed);--}----function _resetVibeWidget(removePanel = false) {-- _clearVibeWidgetTimers();-- _clearVibeWidgetRefreshTimer();-- _vibeWidgetState.key = null;-- _vibeWidgetState.settings = null;-- _vibeWidgetState.publisher = null;-- _vibeWidgetState.events = [];-- _vibeWidgetState.userScrolledUp = false;-- _vibeWidgetState.unreadCount = 0;-- _vibeWidgetState.syncState = 'idle';-- _vibeWidgetState.lastSyncedAt = 0;-- if (removePanel && _vibeWidgetState.panel) {-- _vibeWidgetState.panel.remove();-- _vibeWidgetState.panel = null;-- _vibeWidgetState.feed = null;-- _vibeWidgetState.indicator = null;-- _vibeWidgetState.openDisclosures.clear();-- return;-- }-- if (_vibeWidgetState.feed) {-- _vibeWidgetState.feed.innerHTML = '';-- }-- _hideVibeWidgetNewMessagesIndicator();-- if (_vibeWidgetState.panel) {-- _vibeWidgetState.panel.style.display = 'none';-- }++ return managedStreamId ? { managedStreamId } : null;}--function _getVibeWidgetHost() {-- const { messages } = getChatEl();-- if (!messages) return null;-- return messages.closest('#chat-sidebar, .chat-sidebar, .bc-chat-sidebar, .chat-panel, .bc-ws-panel, .bc-chat-panel')-- || messages.parentElement-- || null;--}----function _ensureVibeWidgetPanel() {-- const host = _getVibeWidgetHost();-- if (!host) return null;-- let panel = host.querySelector('.chat-vibe-widget');-- if (!panel) {-- panel = document.createElement('section');-- panel.className = 'chat-vibe-widget';-- panel.innerHTML = '<div class="chat-vibe-widget-head"><div class="chat-vibe-widget-title"><i class="fa-solid fa-code"></i> <span>Vibe Coding</span></div><div class="chat-vibe-widget-actions"><div class="chat-vibe-widget-status">Live</div><button type="button" class="chat-vibe-widget-toggle" aria-expanded="true">Hide</button></div></div><div class="chat-vibe-widget-feed" tabindex="0" aria-label="Vibe coding event feed"></div><button type="button" class="chat-vibe-new-msgs-indicator is-hidden" aria-hidden="true"><i class="fa-solid fa-arrow-down"></i> New messages below</button>';-- const header = host.querySelector('.chat-header, .global-chat-header');-- if (header) header.after(panel);-- else host.prepend(panel);-- }-- if (panel.dataset.vibeWidgetBound !== '1') {-- const toggle = panel.querySelector('.chat-vibe-widget-toggle');-- if (toggle) {-- toggle.addEventListener('click', () => {-- _toggleVibeWidgetCollapsed();-- });-- }-- panel.dataset.vibeWidgetBound = '1';-- }-- _vibeWidgetState.panel = panel;-- _vibeWidgetState.feed = panel.querySelector('.chat-vibe-widget-feed');-- _vibeWidgetState.indicator = panel.querySelector('.chat-vibe-new-msgs-indicator');-- if (_vibeWidgetState.feed && _vibeWidgetState.feed.dataset.vibeWidgetScrollBound !== '1') {-- _vibeWidgetState.feed.dataset.vibeWidgetScrollBound = '1';-- _vibeWidgetState.feed.addEventListener('scroll', () => {-- _syncVibeWidgetScrollState();-- }, { passive: true });-- _vibeWidgetState.feed.addEventListener('toggle', (event) => {-- const details = event.target.closest('details');-- if (!details || !details.dataset.vibeDisclosureKey) return;-- if (details.open) {-- _vibeWidgetState.openDisclosures.add(details.dataset.vibeDisclosureKey);-- } else {-- _vibeWidgetState.openDisclosures.delete(details.dataset.vibeDisclosureKey);-- }-- });-- }-- if (_vibeWidgetState.indicator && _vibeWidgetState.indicator.dataset.vibeWidgetIndicatorBound !== '1') {-- _vibeWidgetState.indicator.dataset.vibeWidgetIndicatorBound = '1';-- _vibeWidgetState.indicator.addEventListener('click', () => {-- _scrollVibeWidgetToBottom();-- });-- }-- return panel;++function _removeFeedCard() {++ _feedCard.seq++;++ if (_feedCard.frame) _feedCard.frame.remove();++ _feedCard.frame = null;++ _feedCard.key = null;}--function _getVibeWidgetFetchLimit() {-- return Math.max(VIBE_WIDGET_FALLBACK_MAX, parseInt(_vibeWidgetState.settings?.max_events, 10) || 0);++function _feedCardTheme() {++ const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(getComputedStyle(document.body).backgroundColor || '');++ return m && (0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3]) > 140 ? 'light' : 'dark';}--function _getVibeWidgetEventKey(event) {-- if (!event) return '';-- return String(-- event.eventId-- || [event.sessionId || '', event.sequence || '', event.eventType || '', event.summary || '', event.occurredAt || ''].join(':')-- );--}----function _sortVibeWidgetEvents(events) {-- return (Array.isArray(events) ? events.slice() : []).sort((left, right) => {-- const leftSeq = Number(left?.sequence || 0);-- const rightSeq = Number(right?.sequence || 0);-- if (leftSeq && rightSeq && leftSeq !== rightSeq) {-- return leftSeq - rightSeq;-- }---- const leftTime = Date.parse(left?.occurredAt || '') || 0;-- const rightTime = Date.parse(right?.occurredAt || '') || 0;-- if (leftTime !== rightTime) {-- return leftTime - rightTime;-- }---- return _getVibeWidgetEventKey(left).localeCompare(_getVibeWidgetEventKey(right));-- });--}----function _replaceVibeWidgetEvents(events) {-- const deduped = [];-- const seen = new Set();-- _sortVibeWidgetEvents(events).forEach((event) => {-- const key = _getVibeWidgetEventKey(event);-- if (!key || seen.has(key)) return;-- seen.add(key);-- deduped.push(event);-- });-- const maxStored = Math.max(_getVibeWidgetFetchLimit() * 3, VIBE_WIDGET_FALLBACK_MAX);-- _vibeWidgetState.events = deduped.slice(-maxStored);--}----function _mergeVibeWidgetEvents(events) {-- const existingKeys = new Set(_vibeWidgetState.events.map((event) => _getVibeWidgetEventKey(event)));-- const nextEvents = _vibeWidgetState.events.slice();-- let added = 0;-- (Array.isArray(events) ? events : []).forEach((event) => {-- const key = _getVibeWidgetEventKey(event);-- if (!key || existingKeys.has(key)) return;-- existingKeys.add(key);-- nextEvents.push(event);-- added += 1;-- });-- if (!added) return 0;-- _replaceVibeWidgetEvents(nextEvents);-- return added;--}----function _isVibeWidgetNearBottom() {-- const feed = _vibeWidgetState.feed;-- if (!feed) return true;-- return (feed.scrollHeight - feed.scrollTop - feed.clientHeight) < VIBE_WIDGET_SCROLL_THRESHOLD;--}----function _showVibeWidgetNewMessagesIndicator() {-- const indicator = _vibeWidgetState.indicator;-- if (!indicator || _vibeWidgetState.collapsed || !_vibeWidgetState.unreadCount) return;-- indicator.innerHTML = `<i class="fa-solid fa-arrow-down"></i> ${_vibeWidgetState.unreadCount} new message${_vibeWidgetState.unreadCount !== 1 ? 's' : ''} below`;-- indicator.style.display = '';-- indicator.classList.remove('is-hidden');-- indicator.classList.add('is-visible');-- indicator.setAttribute('aria-hidden', 'false');--}----function _hideVibeWidgetNewMessagesIndicator() {-- if (_vibeWidgetState.indicator) {-- _vibeWidgetState.indicator.style.display = '';-- _vibeWidgetState.indicator.classList.remove('is-visible');-- _vibeWidgetState.indicator.classList.add('is-hidden');-- _vibeWidgetState.indicator.setAttribute('aria-hidden', 'true');-- }--}----function _scrollVibeWidgetToBottom() {-- const feed = _vibeWidgetState.feed;-- if (!feed) return;-- feed.scrollTop = feed.scrollHeight;-- _vibeWidgetState.userScrolledUp = false;-- _vibeWidgetState.unreadCount = 0;-- _hideVibeWidgetNewMessagesIndicator();--}----function _syncVibeWidgetScrollState() {-- if (_isVibeWidgetNearBottom()) {-- _vibeWidgetState.userScrolledUp = false;-- _vibeWidgetState.unreadCount = 0;-- _hideVibeWidgetNewMessagesIndicator();-- return;-- }-- _vibeWidgetState.userScrolledUp = true;--}----function _ensureVibeWidgetRefreshTimer() {-- if (_vibeWidgetState.refreshTimer) return;-- _vibeWidgetState.refreshTimer = setInterval(() => {-- if (!_vibeWidgetState.key || document.hidden) return;-- refreshVibeWidgetFeed().catch(() => { });-- }, VIBE_WIDGET_REFRESH_MS);--}----function _formatVibeEventTime(value) {-- const time = Date.parse(value || '');-- if (!time) return '';++async function refreshFeedCard() {++ const context = _feedCardContext();++ if (!context) { _removeFeedCard(); return; }++ const key = String(context.managedStreamId);++ if (_feedCard.key === key && (!_feedCard.frame || _feedCard.frame.isConnected)) return;++ _removeFeedCard();++ _feedCard.key = key;++ const seq = _feedCard.seq;++ let feed = null;try {-- return new Date(time).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });-- } catch {-- return '';-- }--}----function _normalizeVibeDisplayText(value) {-- return String(value || '')-- .replace(/\r/g, ' ')-- .replace(/`+/g, '')-- .replace(/\binlineReference\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, 'reference')-- .replace(/\s*#{1,6}\s*/g, ' ')-- .replace(/\s+([.,;:!?])/g, '$1')-- .replace(/\s+/g, ' ')-- .trim();--}----function _truncateVibeDisplayText(value, maxChars = 84) {-- const text = _normalizeVibeDisplayText(value);-- if (!text) return '';-- if (text.length <= maxChars) return text;-- return `${text.slice(0, Math.max(24, maxChars - 1)).trim()}…`;--}----function _splitVibeNarrative(value, maxSegments = 3) {-- const text = _normalizeVibeDisplayText(value);-- if (!text) return [];-- const parts = text-- .split(/(?<=[.!?])\s+(?=(?:[A-Z0-9✅❌•-]))/g)-- .map((part) => _normalizeVibeDisplayText(part))-- .filter(Boolean);-- if (parts.length <= 1) return [text];-- if (parts.length <= maxSegments) return parts;-- const tailCount = Math.max(1, maxSegments - 1);-- const head = _normalizeVibeDisplayText(parts.slice(0, parts.length - tailCount).join(' '));-- return [head, ...parts.slice(-tailCount)].filter(Boolean);--}----function _extractVibeListParts(value) {-- const text = _normalizeVibeDisplayText(value);-- if (!text || !/\s[•-]\s/.test(text) && !/^[•-]\s/.test(text)) {-- return { lead: text, items: [] };-- }-- const startsWithBullet = /^[•-]\s/.test(text);-- const normalized = startsWithBullet ? text.replace(/^[•-]\s+/, '') : text;-- const parts = normalized-- .split(/\s+[•-]\s+/g)-- .map((part) => _normalizeVibeDisplayText(part))-- .filter(Boolean);-- if (parts.length < 2) {-- return { lead: text, items: [] };-- }-- if (startsWithBullet) {-- return { lead: '', items: parts };-- }-- return { lead: parts[0], items: parts.slice(1) };--}----function _pushUniqueVibeBlock(blocks, blockText) {-- const text = _normalizeVibeDisplayText(blockText);-- if (!text) return;-- const normalized = text.toLowerCase();-- const exactIndex = blocks.findIndex((block) => block.normalized === normalized);-- if (exactIndex >= 0) return;-- const containingIndex = blocks.findIndex((block) => text.includes(block.text) && text.length > block.text.length);-- if (containingIndex >= 0) {-- blocks[containingIndex] = { text, normalized };-- for (let index = blocks.length - 1; index >= 0; index -= 1) {-- if (index === containingIndex) continue;-- if (text.includes(blocks[index].text)) {-- blocks.splice(index, 1);-- }-- }-- return;-- }-- if (blocks.some((block) => block.text.includes(text))) return;-- blocks.push({ text, normalized });--}----function _createVibeTurn(event, key) {-- const occurredAt = event?.occurredAt || '';-- return {-- kind: 'turn',-- key,-- occurredAt,-- lastAt: occurredAt,-- promptText: '',-- responseBlocks: [],-- thinkingBlocks: [],-- sourceLabel: _normalizeVibeDisplayText(event?.publisher?.integrationLabel || ''),-- tools: new Map(),-- files: new Map(),-- };--}----function _isVibeExecutionToolName(toolName) {-- const normalized = _normalizeVibeDisplayText(toolName).toLowerCase();-- return normalized === 'run_in_terminal'-- || normalized === 'create_and_run_task'-- || normalized === 'run_notebook_cell';--}----function _hasMeaningfulVibeToolPreview(tool) {-- const preview = _normalizeVibeDisplayText(tool?.preview || '');-- if (!preview) return false;-- const normalizedName = _normalizeVibeDisplayText(tool?.name || '').toLowerCase();-- return preview.toLowerCase() !== `used ${normalizedName}`;--}----function _formatVibeToolLabel(toolName) {-- const normalized = _normalizeVibeDisplayText(toolName);-- if (!normalized) return 'Tool';-- return normalized-- .replace(/[._-]+/g, ' ')-- .replace(/\s+/g, ' ')-- .trim()-- .replace(/\b\w/g, (char) => char.toUpperCase());--}----function _canReuseLoosePromptTurn(turn, promptText, event) {-- if (!turn || !promptText) return false;-- if (_normalizeVibeDisplayText(turn.promptText).toLowerCase() !== promptText.toLowerCase()) {-- return false;-- }-- return _canMergeLooseVibeEvent(turn, event);--}----function _claimPendingVibePromptTurn(pendingPromptTurns, event) {-- for (let index = 0; index < pendingPromptTurns.length; index += 1) {-- const candidate = pendingPromptTurns[index];-- if (!_canMergeLooseVibeEvent(candidate, event)) continue;-- pendingPromptTurns.splice(index, 1);-- return candidate;-- }-- return null;--}----function _addVibeToolToTurn(turn, event) {-- const toolName = _normalizeVibeDisplayText(event?.tool?.name || event?.summary || 'tool');-- if (!toolName) return;-- const existing = turn.tools.get(toolName);-- const argumentsPreview = _normalizeVibeDisplayText(event?.tool?.argumentsPreview || '');-- const resultPreview = _normalizeVibeDisplayText(event?.tool?.resultPreview || '');-- const preview = [-- argumentsPreview,-- resultPreview,-- event?.summary,-- ]-- .map((value) => _normalizeVibeDisplayText(value))-- .find((value) => value && value.toLowerCase() !== toolName.toLowerCase()) || '';-- turn.tools.set(toolName, {-- name: toolName,-- label: _formatVibeToolLabel(toolName),-- count: (existing?.count || 0) + 1,-- argumentsPreview: argumentsPreview || existing?.argumentsPreview || '',-- resultPreview: resultPreview || existing?.resultPreview || '',-- preview: preview || existing?.preview || '',-- isExecution: _isVibeExecutionToolName(toolName),-- });--}----function _addVibeFileToTurn(turn, event) {-- const fileName = _normalizeVibeDisplayText(event?.file?.relativePath || event?.file?.name || event?.summary || 'file');-- if (!fileName) return;-- const existing = turn.files.get(fileName);-- const operation = event?.file?.operation === 'save' ? 'save' : 'edit';-- const changeCount = Math.max(0, Number(event?.file?.changeCount || 0));-- const snippet = _normalizeVibeDisplayText(event?.file?.snippet || '');-- turn.files.set(fileName, {-- name: fileName,-- count: (existing?.count || 0) + 1,-- changeCount: (existing?.changeCount || 0) + changeCount,-- edited: !!existing?.edited || operation === 'edit',-- saved: !!existing?.saved || operation === 'save',-- snippet: snippet || existing?.snippet || '',-- });--}----function _getExplicitVibeTurnKey(event) {-- const requestId = _normalizeVibeDisplayText(event?.metadata?.requestId);-- const responseId = _normalizeVibeDisplayText(event?.metadata?.responseId);-- if (requestId || responseId) {-- return `turn:${requestId || responseId}:${responseId || ''}`;-- }-- return '';--}----function _ensureVibeTurn(items, turns, key, event) {-- let turn = turns.get(key);-- if (turn) return turn;-- turn = _createVibeTurn(event, key);-- turns.set(key, turn);-- items.push(turn);-- return turn;--}----function _canMergeLooseVibeEvent(turn, event) {-- if (!turn || !event || event?.eventType === 'prompt' || event?.eventType === 'session.status') {-- return false;-- }-- const turnTime = Date.parse(turn.lastAt || turn.occurredAt || '') || 0;-- const eventTime = Date.parse(event?.occurredAt || '') || 0;-- if (turnTime && eventTime && Math.abs(eventTime - turnTime) > (5 * 60 * 1000)) {-- return false;-- }-- return true;--}----function _getVibeTurnTimeValue(turn, preferLast = false) {-- if (!turn) return 0;-- const preferred = Date.parse(preferLast ? (turn.lastAt || turn.occurredAt || '') : (turn.occurredAt || turn.lastAt || '')) || 0;-- if (preferred) return preferred;-- return Date.parse(turn.occurredAt || turn.lastAt || '') || 0;--}----function _getVibeTurnTextValues(turn) {-- const values = [];-- if (turn?.promptText) values.push(_normalizeVibeDisplayText(turn.promptText));-- (Array.isArray(turn?.responseBlocks) ? turn.responseBlocks : []).forEach((block) => values.push(_normalizeVibeDisplayText(block?.text || block)));-- (Array.isArray(turn?.thinkingBlocks) ? turn.thinkingBlocks : []).forEach((block) => values.push(_normalizeVibeDisplayText(block?.text || block)));-- return values.filter(Boolean);--}----function _getVibeTextTokenCount(leftText, rightText) {-- const leftTokens = Array.from(new Set(_normalizeVibeDisplayText(leftText).toLowerCase().split(/[^a-z0-9]+/g).filter((token) => token.length >= 4)));-- if (!leftTokens.length) return 0;-- const rightTokens = new Set(_normalizeVibeDisplayText(rightText).toLowerCase().split(/[^a-z0-9]+/g).filter((token) => token.length >= 4));-- return leftTokens.reduce((count, token) => count + (rightTokens.has(token) ? 1 : 0), 0);--}----function _getVibeTurnOverlapScore(sourceTurn, targetTurn) {-- const sourceTexts = _getVibeTurnTextValues(sourceTurn);-- const targetTexts = _getVibeTurnTextValues(targetTurn);-- let bestScore = 0;-- sourceTexts.forEach((sourceText) => {-- targetTexts.forEach((targetText) => {-- if (!sourceText || !targetText) return;-- if (sourceText === targetText) {-- bestScore = Math.max(bestScore, 100);-- return;-- }-- if (targetText.includes(sourceText) || sourceText.includes(targetText)) {-- bestScore = Math.max(bestScore, 60);-- return;-- }-- bestScore = Math.max(bestScore, _getVibeTextTokenCount(sourceText, targetText));-- });-- });-- return bestScore;--}----function _mergeVibeBlockHistory(existingBlocks, incomingBlocks, placeIncomingFirst) {-- const ordered = placeIncomingFirst-- ? [...(incomingBlocks || []), ...(existingBlocks || [])]-- : [...(existingBlocks || []), ...(incomingBlocks || [])];-- const merged = [];-- ordered.forEach((block) => {-- const text = _normalizeVibeDisplayText(block?.text || block);-- if (!text) return;-- const normalized = text.toLowerCase();-- if (merged.some((item) => item.normalized === normalized)) return;-- merged.push({ text, normalized });-- });-- return merged;--}----function _mergeVibeTurnIntoTarget(targetTurn, sourceTurn) {-- if (!targetTurn || !sourceTurn || targetTurn === sourceTurn) return;-- const sourceTime = _getVibeTurnTimeValue(sourceTurn, true);-- const targetStart = _getVibeTurnTimeValue(targetTurn, false);-- const placeIncomingFirst = !!sourceTime && !!targetStart && sourceTime <= targetStart;---- targetTurn.responseBlocks = _mergeVibeBlockHistory(targetTurn.responseBlocks, sourceTurn.responseBlocks, placeIncomingFirst);-- targetTurn.thinkingBlocks = _mergeVibeBlockHistory(targetTurn.thinkingBlocks, sourceTurn.thinkingBlocks, placeIncomingFirst);---- sourceTurn.tools.forEach((tool, toolName) => {-- const existing = targetTurn.tools.get(toolName);-- targetTurn.tools.set(toolName, {-- name: tool.name,-- label: existing?.label || tool?.label || _formatVibeToolLabel(tool.name),-- count: (existing?.count || 0) + (tool?.count || 0),-- argumentsPreview: existing?.argumentsPreview || tool?.argumentsPreview || '',-- resultPreview: existing?.resultPreview || tool?.resultPreview || '',-- preview: existing?.preview || tool?.preview || '',-- isExecution: !!existing?.isExecution || !!tool?.isExecution,-- });-- });---- sourceTurn.files.forEach((file, fileName) => {-- const existing = targetTurn.files.get(fileName);-- targetTurn.files.set(fileName, {-- name: file.name,-- count: (existing?.count || 0) + (file?.count || 0),-- changeCount: (existing?.changeCount || 0) + (file?.changeCount || 0),-- edited: !!existing?.edited || !!file?.edited,-- saved: !!existing?.saved || !!file?.saved,-- snippet: existing?.snippet || file?.snippet || '',-- });-- });---- if (!targetTurn.sourceLabel && sourceTurn.sourceLabel) {-- targetTurn.sourceLabel = sourceTurn.sourceLabel;-- }-- if (!targetTurn.occurredAt || (sourceTurn.occurredAt && Date.parse(sourceTurn.occurredAt) < Date.parse(targetTurn.occurredAt))) {-- targetTurn.occurredAt = sourceTurn.occurredAt || targetTurn.occurredAt;-- }-- if (!targetTurn.lastAt || (sourceTurn.lastAt && Date.parse(sourceTurn.lastAt) > Date.parse(targetTurn.lastAt))) {-- targetTurn.lastAt = sourceTurn.lastAt || targetTurn.lastAt;-- }--}----function _getVibeLooseMergeScore(sourceTurn, targetTurn) {-- if (!sourceTurn || !targetTurn || sourceTurn === targetTurn || !targetTurn.promptText || sourceTurn.promptText) {-- return -1;-- }---- const sourceTime = _getVibeTurnTimeValue(sourceTurn, true);-- const targetStart = _getVibeTurnTimeValue(targetTurn, false);-- const targetEnd = _getVibeTurnTimeValue(targetTurn, true) || targetStart;-- if (sourceTime && targetStart && (sourceTime < (targetStart - (3 * 60 * 1000)) || sourceTime > (targetEnd + (4 * 60 * 1000)))) {-- return -1;-- }---- let score = _getVibeTurnOverlapScore(sourceTurn, targetTurn);-- if ((sourceTurn.tools.size || sourceTurn.files.size) && sourceTime && targetStart) {-- score += sourceTime >= (targetStart - (60 * 1000)) ? 25 : 10;-- }---- if (sourceTime && targetEnd) {-- const distance = Math.abs(sourceTime - targetEnd);-- score += Math.max(0, 12 - Math.floor(distance / 30000));-- }---- return score;--}----function _normalizeVibeWidgetDisplayItems(items) {-- const turns = items.filter((item) => item?.kind === 'turn');-- const removedKeys = new Set();---- turns.forEach((turn) => {-- if (turn.promptText) return;-- let bestTarget = null;-- let bestScore = -1;-- turns.forEach((candidate) => {-- const score = _getVibeLooseMergeScore(turn, candidate);-- if (score > bestScore) {-- bestScore = score;-- bestTarget = candidate;-- }-- });---- if (bestTarget && bestScore >= 8) {-- _mergeVibeTurnIntoTarget(bestTarget, turn);-- removedKeys.add(turn.key);-- }-- });---- return items-- .filter((item) => !removedKeys.has(item?.key))-- .sort((left, right) => {-- const leftTime = left?.kind === 'turn'-- ? _getVibeTurnTimeValue(left, false)-- : (Date.parse(left?.occurredAt || '') || 0);-- const rightTime = right?.kind === 'turn'-- ? _getVibeTurnTimeValue(right, false)-- : (Date.parse(right?.occurredAt || '') || 0);-- if (leftTime !== rightTime) return leftTime - rightTime;-- return String(left?.key || '').localeCompare(String(right?.key || ''));-- });--}----function _buildVibeWidgetDisplayItems(events) {-- const items = [];-- const turns = new Map();-- const pendingPromptTurns = [];-- let activeLooseTurn = null;-- let lastLoosePromptTurn = null;-- let looseCounter = 0;---- _sortVibeWidgetEvents(events).forEach((event, index) => {-- if (event?.eventType === 'session.status') {-- items.push({-- kind: 'system',-- key: `system:${_getVibeWidgetEventKey(event) || index}`,-- occurredAt: event?.occurredAt || '',-- text: _normalizeVibeDisplayText(event?.summary || 'Copilot session update'),-- });-- activeLooseTurn = null;-- lastLoosePromptTurn = null;-- return;-- }---- const explicitKey = _getExplicitVibeTurnKey(event);-- let turn = null;-- if (event?.eventType === 'prompt') {-- const promptText = _normalizeVibeDisplayText(event?.prompt?.text || event?.summary);-- if (!explicitKey && _canReuseLoosePromptTurn(lastLoosePromptTurn, promptText, event)) {-- turn = lastLoosePromptTurn;-- } else {-- turn = _ensureVibeTurn(items, turns, explicitKey || `prompt:${_getVibeWidgetEventKey(event) || index}`, event);-- }-- activeLooseTurn = turn;-- if (!explicitKey && !pendingPromptTurns.includes(turn)-- && !turn.responseBlocks.length-- && !turn.thinkingBlocks.length-- && !turn.tools.size-- && !turn.files.size) {-- pendingPromptTurns.push(turn);-- }-- lastLoosePromptTurn = explicitKey ? null : turn;-- } else if (explicitKey) {-- turn = _ensureVibeTurn(items, turns, explicitKey, event);-- activeLooseTurn = turn;-- lastLoosePromptTurn = null;-- } else {-- const claimedPromptTurn = _claimPendingVibePromptTurn(pendingPromptTurns, event);-- if (claimedPromptTurn) {-- turn = claimedPromptTurn;-- } else if (_canMergeLooseVibeEvent(activeLooseTurn, event)) {-- turn = activeLooseTurn;-- } else {-- turn = _ensureVibeTurn(items, turns, `loose-group:${looseCounter += 1}:${_getVibeWidgetEventKey(event) || index}`, event);-- }-- activeLooseTurn = turn;-- }---- if (event?.occurredAt) {-- if (!turn.occurredAt || Date.parse(event.occurredAt) < Date.parse(turn.occurredAt)) {-- turn.occurredAt = event.occurredAt;-- }-- if (!turn.lastAt || Date.parse(event.occurredAt) > Date.parse(turn.lastAt)) {-- turn.lastAt = event.occurredAt;-- }-- }-- if (!turn.sourceLabel && event?.publisher?.integrationLabel) {-- turn.sourceLabel = _normalizeVibeDisplayText(event.publisher.integrationLabel);-- }---- if (event?.eventType === 'prompt') {-- const promptText = _normalizeVibeDisplayText(event?.prompt?.text || event?.summary);-- if (promptText) turn.promptText = promptText;-- return;-- }---- if (event?.eventType === 'response') {-- _pushUniqueVibeBlock(turn.responseBlocks, event?.response?.text || event?.summary);-- return;-- }---- if (event?.eventType === 'thinking') {-- _pushUniqueVibeBlock(turn.thinkingBlocks, event?.thinking?.text || event?.summary);-- return;-- }---- if (event?.eventType === 'tool.call') {-- _addVibeToolToTurn(turn, event);-- return;-- }---- if (event?.eventType === 'file.change' || event?.eventType === 'file.save') {-- _addVibeFileToTurn(turn, event);-- }-- });---- const filteredItems = items.filter((item) => item.kind === 'system'-- || item.promptText-- || item.responseBlocks.length-- || item.thinkingBlocks.length-- || item.tools.size-- || item.files.size);---- return _normalizeVibeWidgetDisplayItems(filteredItems);--}----function _renderVibeDisclosure(kind, label, preview, meta, bodyHtml, disclosureKey) {-- if (!bodyHtml) return '';-- const disclosureId = disclosureKey ? ` data-vibe-disclosure-key="${esc(disclosureKey)}"` : '';-- const isOpen = disclosureKey && _vibeWidgetState.openDisclosures.has(disclosureKey);-- return `<details class="chat-vibe-turn-disclosure chat-vibe-turn-disclosure-${esc(kind)}"${disclosureId}${isOpen ? ' open' : ''}><summary><span class="chat-vibe-turn-disclosure-kicker">${esc(label)}</span><span class="chat-vibe-turn-disclosure-title">${esc(preview || label)}</span>${meta ? `<span class="chat-vibe-turn-disclosure-meta">${esc(meta)}</span>` : ''}</summary><div class="chat-vibe-turn-disclosure-body">${bodyHtml}</div></details>`;--}----function _getVibeWidgetItemDomKey(item, index) {-- return String(item?.key || `vibe-item:${index}`);--}----function _createVibeWidgetItemNode(item, itemKey) {-- const html = item.kind === 'system' ? _renderVibeSystemItem(item) : _renderVibeTurnItem(item);-- const wrapper = document.createElement('div');-- wrapper.innerHTML = html;-- const node = wrapper.firstElementChild;-- if (!node) return null;-- node.dataset.vibeKey = itemKey;-- return node;--}----function _renderVibeParagraphs(text, maxSegments = 3, muted = false) {-- return _splitVibeNarrative(text, maxSegments)-- .map((part) => `<p class="chat-vibe-turn-paragraph${muted ? ' is-muted' : ''}">${esc(part)}</p>`)-- .join('');--}----function _renderVibeRichText(text, maxSegments = 3, muted = false) {-- const { lead, items } = _extractVibeListParts(text);-- if (!items.length) {-- return _renderVibeParagraphs(text, maxSegments, muted);-- }-- const leadHtml = lead ? _renderVibeParagraphs(lead, Math.max(1, maxSegments - 1), muted) : '';-- const listHtml = `<ul class="chat-vibe-turn-bullets">${items.map((item) => `<li class="chat-vibe-turn-list-item${muted ? ' is-muted' : ''}">${esc(item)}</li>`).join('')}</ul>`;-- return `${leadHtml}${listHtml}`;--}----function _renderVibeSystemItem(item) {-- const timestamp = _formatVibeEventTime(item?.occurredAt);-- return `<div class="chat-vibe-system">${timestamp ? `<span class="chat-vibe-system-time">${esc(timestamp)}</span>` : ''}<span class="chat-vibe-system-text">${esc(item?.text || 'Copilot session update')}</span></div>`;--}----function _renderVibeTurnItem(turn) {-- const hasSecondarySections = turn.thinkingBlocks.length || turn.tools.size || turn.files.size;-- const turnClasses = ['chat-vibe-turn'];-- if (!turn.promptText) turnClasses.push('is-compact');-- if (!turn.promptText && !hasSecondarySections) turnClasses.push('is-update');-- const promptHtml = turn.promptText-- ? `<div class="chat-vibe-turn-prompt-row"><div class="chat-vibe-turn-prompt">${esc(turn.promptText)}</div></div>`-- : '';-- const responseBlocks = Array.isArray(turn.responseBlocks) ? turn.responseBlocks : [];-- const primaryResponse = responseBlocks.length ? responseBlocks[responseBlocks.length - 1].text : '';-- let progressBlocks = responseBlocks.slice(0, -1);-- let thinkingBlocks = turn.thinkingBlocks.slice();-- let visibleFallbackText = '';-- let fallbackMuted = false;---- if (!primaryResponse && progressBlocks.length) {-- visibleFallbackText = progressBlocks[progressBlocks.length - 1].text;-- progressBlocks = progressBlocks.slice(0, -1);-- fallbackMuted = false;-- } else if (!primaryResponse && thinkingBlocks.length) {-- visibleFallbackText = thinkingBlocks[thinkingBlocks.length - 1].text;-- thinkingBlocks = thinkingBlocks.slice(0, -1);-- fallbackMuted = true;-- }---- const responseHtml = primaryResponse-- ? _renderVibeRichText(primaryResponse, 3, false)-- : visibleFallbackText-- ? _renderVibeRichText(visibleFallbackText, 2, fallbackMuted)-- : '<p class="chat-vibe-turn-paragraph is-muted">Working…</p>';---- const disclosureBase = turn.key || `turn:${turn.occurredAt || turn.lastAt || Date.now()}`;-- const progressHtml = progressBlocks.length-- ? _renderVibeDisclosure(-- 'progress',-- 'Progress',-- _truncateVibeDisplayText(progressBlocks[progressBlocks.length - 1].text, 90),-- `${progressBlocks.length} update${progressBlocks.length === 1 ? '' : 's'}`,-- progressBlocks.map((block) => `<p class="chat-vibe-turn-note">${esc(block.text)}</p>`).join(''),-- `${disclosureBase}:progress`-- )-- : '';---- const thinkingHtml = thinkingBlocks.length-- ? _renderVibeDisclosure(-- 'thinking',-- 'Thinking',-- _truncateVibeDisplayText(thinkingBlocks[thinkingBlocks.length - 1].text, 90),-- `${thinkingBlocks.length} note${thinkingBlocks.length === 1 ? '' : 's'}`,-- thinkingBlocks.map((block) => `<p class="chat-vibe-turn-note">${esc(block.text)}</p>`).join(''),-- `${disclosureBase}:thinking`-- )-- : '';---- const toolEntries = Array.from(turn.tools.values()).sort((left, right) => {-- if ((right?.count || 0) !== (left?.count || 0)) {-- return (right?.count || 0) - (left?.count || 0);-- }-- return String(left?.name || '').localeCompare(String(right?.name || ''));-- });-- const commandEntries = toolEntries.filter((tool) => tool.isExecution);-- const otherToolEntries = toolEntries.filter((tool) => !tool.isExecution);-- const commandTotal = commandEntries.reduce((sum, tool) => sum + (tool.count || 0), 0);-- const commandHtml = commandEntries.length-- ? _renderVibeDisclosure(-- 'commands',-- 'Commands',-- _truncateVibeDisplayText(commandEntries[commandEntries.length - 1].argumentsPreview || commandEntries[commandEntries.length - 1].preview || commandEntries[0].name, 96),-- `${commandTotal} run${commandTotal === 1 ? '' : 's'}`,-- commandEntries.map((tool) => {-- const subtitle = tool.resultPreview-- ? 'Completed execution'-- : tool.argumentsPreview-- ? 'Ran command'-- : 'Executed tool';-- return `-- <div class="chat-vibe-turn-file-card">-- <div class="chat-vibe-turn-list-row">-- <div class="chat-vibe-turn-list-main">-- <div class="chat-vibe-turn-list-title">${esc(tool.label || tool.name)}</div>-- <div class="chat-vibe-turn-list-subtitle">${esc(subtitle)}</div>-- </div>-- <div class="chat-vibe-turn-pill-group"><span class="chat-vibe-turn-pill is-count">${esc(tool.count > 1 ? `x${tool.count}` : '1x')}</span></div>-- </div>-- ${tool.argumentsPreview ? `<p class="chat-vibe-turn-note chat-vibe-turn-note-mono is-command">${esc(_truncateVibeDisplayText(tool.argumentsPreview, 180))}</p>` : ''}-- ${tool.resultPreview ? `<p class="chat-vibe-turn-note">${esc(_truncateVibeDisplayText(tool.resultPreview, 180))}</p>` : ''}-- </div>`;-- }).join(''),-- `${disclosureBase}:commands`-- )-- : '';-- const toolTotal = otherToolEntries.reduce((sum, tool) => sum + (tool.count || 0), 0);-- const toolsHtml = otherToolEntries.length-- ? _renderVibeDisclosure(-- 'tools',-- 'Tools',-- otherToolEntries.slice(0, 2).map((tool) => tool.label || tool.name).join(', '),-- `${toolTotal} call${toolTotal === 1 ? '' : 's'}`,-- otherToolEntries.map((tool) => {-- const subtitle = _hasMeaningfulVibeToolPreview(tool)-- ? _truncateVibeDisplayText(tool.preview, 120)-- : '';-- return `-- <div class="chat-vibe-turn-list-row">-- <div class="chat-vibe-turn-list-main">-- <div class="chat-vibe-turn-list-title">${esc(tool.label || tool.name)}</div>-- ${subtitle ? `<div class="chat-vibe-turn-list-subtitle">${esc(subtitle)}</div>` : ''}-- </div>-- <div class="chat-vibe-turn-pill-group"><span class="chat-vibe-turn-pill is-count">${esc(tool.count > 1 ? `x${tool.count}` : '1x')}</span></div>-- </div>`;-- }).join(''),-- `${disclosureBase}:tools`-- )-- : '';---- const fileEntries = Array.from(turn.files.values()).sort((left, right) => {-- if ((right?.changeCount || 0) !== (left?.changeCount || 0)) {-- return (right?.changeCount || 0) - (left?.changeCount || 0);-- }-- return String(left?.name || '').localeCompare(String(right?.name || ''));-- });-- const filesHtml = fileEntries.length-- ? _renderVibeDisclosure(-- 'files',-- 'Edited files',-- fileEntries.slice(0, 2).map((file) => file.name).join(', '),-- `${fileEntries.length} file${fileEntries.length === 1 ? '' : 's'}`,-- fileEntries.map((file) => {-- const badges = [];-- if (file.changeCount > 0) {-- badges.push(`<span class="chat-vibe-turn-pill is-edit">${esc(`${file.changeCount} edit${file.changeCount === 1 ? '' : 's'}`)}</span>`);-- }-- if (file.saved) {-- badges.push('<span class="chat-vibe-turn-pill is-save">saved</span>');-- }-- if (!badges.length && file.edited) {-- badges.push('<span class="chat-vibe-turn-pill is-edit">edited</span>');-- }-- return `-- <div class="chat-vibe-turn-file-card">-- <div class="chat-vibe-turn-list-row">-- <div class="chat-vibe-turn-list-main">-- <div class="chat-vibe-turn-list-title is-mono">${esc(file.name)}</div>-- <div class="chat-vibe-turn-list-subtitle">${esc(file.saved && file.edited ? 'Edited and saved during this turn' : file.saved ? 'Saved in workspace' : 'Edited in workspace')}</div>-- </div>-- <div class="chat-vibe-turn-pill-group">${badges.join('')}</div>-- </div>-- ${file.snippet ? `<p class="chat-vibe-turn-note chat-vibe-turn-note-mono">${esc(_truncateVibeDisplayText(file.snippet, 180))}</p>` : ''}-- </div>`;-- }).join(''),-- `${disclosureBase}:files`-- )-- : '';---- return `-- <article class="${turnClasses.join(' ')}">-- ${promptHtml}-- <div class="chat-vibe-turn-response">-- <div class="chat-vibe-turn-response-top">-- <span class="chat-vibe-turn-time">${esc(_formatVibeEventTime(turn.lastAt || turn.occurredAt))}</span>-- </div>-- <div class="chat-vibe-turn-body">${responseHtml}</div>-- ${progressHtml}-- ${thinkingHtml}-- ${commandHtml}-- ${toolsHtml}-- ${filesHtml}-- </div>-- </article>`;--}----function _renderVibeWidget() {-- const panel = _ensureVibeWidgetPanel();-- if (!panel || !_vibeWidgetState.feed) return;-- const settings = _vibeWidgetState.settings;-- if (!settings?.enabled) {-- _resetVibeWidget(true);-- return;-- }---- panel.style.display = '';-- panel.classList.toggle('is-collapsed', !!_vibeWidgetState.collapsed);-- const titleEl = panel.querySelector('.chat-vibe-widget-title span');-- if (titleEl) titleEl.textContent = settings.widget_title || 'Vibe Coding';-- const statusEl = panel.querySelector('.chat-vibe-widget-status');-- if (statusEl) {-- const shouldShowBadge = _vibeWidgetState.collapsed && _vibeWidgetState.unreadCount > 0;-- statusEl.classList.toggle('is-badge', shouldShowBadge);-- statusEl.classList.toggle('is-hidden', _vibeWidgetState.collapsed && _vibeWidgetState.unreadCount === 0);---- if (shouldShowBadge) {-- statusEl.textContent = `${_vibeWidgetState.unreadCount} new`;-- } else if (_vibeWidgetState.collapsed) {-- statusEl.textContent = '';-- } else {-- let statusText = 'Live';-- if (settings.paused) statusText = 'Paused';-- else if (_vibeWidgetState.syncState === 'retrying') statusText = 'Reconnecting';-- statusEl.textContent = statusText;-- statusEl.classList.remove('is-badge');-- statusEl.classList.remove('is-hidden');-- }-- }-- const toggleEl = panel.querySelector('.chat-vibe-widget-toggle');-- if (toggleEl) {-- toggleEl.textContent = _vibeWidgetState.collapsed ? 'Show' : 'Hide';-- toggleEl.setAttribute('aria-expanded', _vibeWidgetState.collapsed ? 'false' : 'true');-- toggleEl.setAttribute('aria-label', _vibeWidgetState.collapsed ? 'Expand vibe coding widget' : 'Collapse vibe coding widget');-- }-- if (_vibeWidgetState.collapsed) {-- _hideVibeWidgetNewMessagesIndicator();-- }---- const maxEvents = settings.max_events || VIBE_WIDGET_FALLBACK_MAX;-- const previousOffsetFromBottom = _vibeWidgetState.feed.scrollHeight - _vibeWidgetState.feed.scrollTop;-- const shouldStickToBottom = !_vibeWidgetState.userScrolledUp || _isVibeWidgetNearBottom();-- const items = _buildVibeWidgetDisplayItems(_vibeWidgetState.events).slice(-maxEvents);-- if (!items.length) {-- _vibeWidgetState.feed.innerHTML = '<div class="chat-vibe-widget-empty">No coding events yet for this slot.</div>';-- _hideVibeWidgetNewMessagesIndicator();-- return;-- }---- const existingChildren = Array.from(_vibeWidgetState.feed.children);-- const existingByKey = new Map(existingChildren.map((child) => [child.dataset.vibeKey, child]));-- const nextChildren = [];---- items.forEach((item, index) => {-- const itemKey = _getVibeWidgetItemDomKey(item, index);-- const existing = existingByKey.get(itemKey);-- const itemHtml = item.kind === 'system' ? _renderVibeSystemItem(item) : _renderVibeTurnItem(item);---- if (existing) {-- if (existing.innerHTML !== itemHtml) {-- const replacement = _createVibeWidgetItemNode(item, itemKey);-- if (replacement) {-- _vibeWidgetState.feed.replaceChild(replacement, existing);-- nextChildren.push(replacement);-- }-- } else {-- nextChildren.push(existing);-- }-- return;-- }---- const node = _createVibeWidgetItemNode(item, itemKey);-- if (!node) return;-- const insertBefore = _vibeWidgetState.feed.children[index] || null;-- _vibeWidgetState.feed.insertBefore(node, insertBefore);-- nextChildren.push(node);-- });---- const nextKeys = new Set(nextChildren.map((child) => child.dataset.vibeKey));-- existingChildren.forEach((child) => {-- if (!nextKeys.has(child.dataset.vibeKey)) {-- child.remove();-- }-- });---- requestAnimationFrame(() => {-- if (!_vibeWidgetState.feed) return;-- if (shouldStickToBottom) {-- _scrollVibeWidgetToBottom();-- return;-- }-- _vibeWidgetState.feed.scrollTop = Math.max(0, _vibeWidgetState.feed.scrollHeight - previousOffsetFromBottom);-- _showVibeWidgetNewMessagesIndicator();-- });--}----function _pushVibeWidgetEvent(event) {-- if (!event) return;-- if (event.publisher) {-- _vibeWidgetState.publisher = event.publisher;-- }-- const wasNearBottom = _isVibeWidgetNearBottom();-- const addedCount = _mergeVibeWidgetEvents([event]);-- if (!addedCount) {-- return;-- }-- _vibeWidgetState.lastSyncedAt = Date.now();-- _vibeWidgetState.syncState = 'live';-- if (!wasNearBottom) {-- _vibeWidgetState.userScrolledUp = true;-- _vibeWidgetState.unreadCount += addedCount;-- } else {-- _vibeWidgetState.userScrolledUp = false;-- _vibeWidgetState.unreadCount = 0;-- }-- _renderVibeWidget();--}----function _handleVibeSocketMessage(msg) {-- if (!msg?.event || !_vibeWidgetState.settings?.enabled) return;-- const delayMs = Math.max(0, parseInt(msg.delay_ms, 10) || parseInt(_vibeWidgetState.settings.delay_ms, 10) || 0);-- if (delayMs > 0) {-- const timerId = setTimeout(() => {-- _vibeWidgetState.timers = _vibeWidgetState.timers.filter((id) => id !== timerId);-- _pushVibeWidgetEvent(msg.event);-- }, delayMs);-- _vibeWidgetState.timers.push(timerId);-- return;-- }-- _pushVibeWidgetEvent(msg.event);--}----async function refreshVibeWidgetFeed() {-- const context = _getVibeWidgetContext();-- if (!context) {-- _resetVibeWidget(true);-- return;-- }---- const widgetKey = `${context.username}:${context.slotRef}`;-- if (_vibeWidgetState.key && _vibeWidgetState.key !== widgetKey) {-- _resetVibeWidget(true);-- }---- const previousKeys = new Set(_vibeWidgetState.events.map((event) => _getVibeWidgetEventKey(event)));-- const wasNearBottom = _isVibeWidgetNearBottom();-- try {-- const data = await api(`/vibe-coding/channel/${encodeURIComponent(context.username)}/${encodeURIComponent(String(context.slotRef))}/events?limit=${_getVibeWidgetFetchLimit()}`);-- _vibeWidgetState.key = widgetKey;-- _vibeWidgetState.settings = data.settings || null;-- _vibeWidgetState.publisher = data.publisher || null;-- _replaceVibeWidgetEvents(Array.isArray(data.events) ? data.events : []);-- const addedCount = _vibeWidgetState.events.reduce((count, event) => {-- return count + (previousKeys.has(_getVibeWidgetEventKey(event)) ? 0 : 1);-- }, 0);-- _vibeWidgetState.lastSyncedAt = Date.now();-- _vibeWidgetState.syncState = 'live';-- if (!wasNearBottom && addedCount > 0) {-- _vibeWidgetState.userScrolledUp = true;-- _vibeWidgetState.unreadCount += addedCount;-- } else if (wasNearBottom) {-- _vibeWidgetState.userScrolledUp = false;-- _vibeWidgetState.unreadCount = 0;-- }-- _ensureVibeWidgetRefreshTimer();-- _renderVibeWidget();-- } catch {-- _vibeWidgetState.syncState = _vibeWidgetState.settings?.enabled ? 'retrying' : 'idle';-- if (_vibeWidgetState.panel && _vibeWidgetState.settings?.enabled) {-- _renderVibeWidget();-- }-- }--}++ const res = await fetch(`${FEED_ORIGIN}/api/chat/feeds?slot=${encodeURIComponent(key)}`, { credentials: 'omit' });++ feed = res.ok ? ((await res.json()).feeds || [])[0] || null : null;++ } catch { feed = null; }++ if (seq !== _feedCard.seq || !feed) return;++ const { messages } = getChatEl();++ const host = messages && (messages.closest('#chat-sidebar, .chat-sidebar, .bc-chat-sidebar, .chat-panel, .bc-ws-panel, .bc-chat-panel') || messages.parentElement);++ if (!host) return;++ const frame = document.createElement('iframe');++ frame.className = 'chat-feed-card';++ frame.title = feed.title || 'Vibe coding, live';++ frame.src = `${FEED_ORIGIN}/embed/feed/${encodeURIComponent(feed.id)}?theme=${_feedCardTheme()}`;++ const header = host.querySelector('.chat-header, .global-chat-header');++ if (header) header.after(frame); else host.prepend(frame);++ _feedCard.frame = frame;++}++++window.addEventListener('message', (e) => {++ if (e.origin !== FEED_ORIGIN || !_feedCard.frame || e.source !== _feedCard.frame.contentWindow) return;++ if (!e.data || e.data.ov !== 'chat' || e.data.type !== 'feed-height') return;++ _feedCard.frame.style.height = `${Math.min(340, Math.max(42, Number(e.data.height) || 0))}px`;++});function getFullscreenChatEls() {return {@@@ -2628,7 +1546,7 @@ function initChat(streamId, channelUserId = null) {prevInput.value = '';try { _autoResizeTextarea(nextInput); } catch { /* */ }}-- refreshVibeWidgetFeed().catch(() => { });++ refreshFeedCard().catch(() => { });applyChatSettings();scrollChatToBottom();return;@@@ -2644,7 +1562,7 @@ function initChat(streamId, channelUserId = null) {if (targetChanged || needsHydrate) {hydrateActiveChatHistory(streamId, { clear: true }).catch(() => { });}-- refreshVibeWidgetFeed().catch(() => { });++ refreshFeedCard().catch(() => { });applyChatSettings();return;}@@@ -2652,7 +1570,7 @@ function initChat(streamId, channelUserId = null) {// Reclaim background broadcast WS if it's connected to the same streamif (_bgBroadcastWs && _bgBroadcastWs.readyState === WebSocket.OPEN && _bgBroadcastStreamId === streamId) {-- refreshVibeWidgetFeed().catch(() => { });++ refreshFeedCard().catch(() => { });destroyChat(); // close any existing foreground chatchatChannelUserId = channelUserId || null; // destroyChat() clears the old page's channel; this is the new page's_syncOwnTtsLeadership();@@@ -2692,7 +1610,7 @@ function initChat(streamId, channelUserId = null) {_syncOwnTtsLeadership();chatStreamId = streamId;chatRenderTargetId = nextTargetId;-- refreshVibeWidgetFeed().catch(() => { });++ refreshFeedCard().catch(() => { });const host = window.location.hostname;const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');@@@ -2918,7 +1836,7 @@ function _reconnectChatWs(streamId) {}function destroyChat(forceClose = false) {-- _resetVibeWidget(true);++ _removeFeedCard();_chatIntentionalClose = true;_chatActive = false;_closeGlobalFeed();@@@ -3184,9 +2102,6 @@ function _applyChatVipBadge(evt) {function handleChatMessage(msg) {switch (msg.type) {-- case 'vibe-coding':-- _handleVibeSocketMessage(msg);-- break;case 'chat_translation':_applyChatTranslation(msg);break;@@@ -875,12 +875,6 @@ const DASH_TOKEN_SCOPE_FALLBACKS = Object.freeze([title: 'Stream control',description: 'Start or stop streams and update stream state or metadata.',},-- {-- id: 'vibe_coding_publish',-- label: 'vibe_coding_publish',-- title: 'Vibe coding publisher',-- description: 'Publish sanitized coding-feed events to /ws/vibe-coding/publish for a managed stream slot.',-- },]);const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([{@@@ -890,13 +884,6 @@ const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([suggested_label: 'Chat Bot',scopes: ['chat', 'read'],},-- {-- id: 'vibe-coding-publisher',-- label: 'GitHub Copilot Companion',-- description: 'Recommended for the OpenVibe.Live VS Code companion and other coding-feed publishers.',-- suggested_label: 'Copilot Companion',-- scopes: ['read', 'vibe_coding_publish'],-- },{id: 'stream-controller',label: 'Stream Controller',@@@ -1077,7 +1064,7 @@ function showCreateTokenModal() {<div style="display:flex;flex-direction:column;gap:6px">${renderDashTokenScopeOptions(['chat', 'read'])}</div>-- <p class="muted" style="font-size:0.78rem;margin-top:8px">Use <strong>vibe_coding_publish</strong> for coding-feed publishers such as the OpenVibe.Live VS Code companion.</p>++ <p class="muted" style="font-size:0.78rem;margin-top:8px">Showing your coding session live? Feeds have their own key on <a href="https://openvibe.chat/feeds" target="_blank" rel="noopener">openvibe.chat/feeds</a>.</p></div><div class="form-group"><label>Expires</label>@@@ -214,7 +214,6 @@ const TOKEN_WRITE_SCOPES = [['/api/chat', ['chat']], ['/api/dm', ['chat']], ['/api/emotes', ['chat']], ['/api/sounds', ['chat']], ['/api/tts', ['chat']],['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],-- ['/api/vibe-coding', ['vibe_coding_publish']], // not the broad `stream` scope];function apiTokenAllows(req, scopes) {const path = String(req.originalUrl || req.url || '').split('?')[0];@@@ -704,12 +704,6 @@ const TOKEN_SCOPE_DEFINITIONS = Object.freeze([title: 'Stream control',description: 'Start or stop streams and update stream state or metadata.',},-- {-- id: 'vibe_coding_publish',-- label: 'vibe_coding_publish',-- title: 'Vibe coding publisher',-- description: 'Publish sanitized coding-feed events to /ws/vibe-coding/publish for a managed stream slot.',-- },]);const TOKEN_PRESET_DEFINITIONS = Object.freeze([@@@ -720,13 +714,6 @@ const TOKEN_PRESET_DEFINITIONS = Object.freeze([suggested_label: 'Chat Bot',scopes: ['chat', 'read'],},-- {-- id: 'vibe-coding-publisher',-- label: 'GitHub Copilot Companion',-- description: 'Recommended for the OpenVibe.Live VS Code companion and other coding-feed publishers.',-- suggested_label: 'Copilot Companion',-- scopes: ['read', 'vibe_coding_publish'],-- },{id: 'stream-controller',label: 'Stream Controller',@@@ -109,7 +109,6 @@ const rtmpServer = require('./streaming/rtmp-server');// Real-timeconst chatDelivery = require('./chat/chat-delivery');const broadcastServer = require('./streaming/broadcast-server');--const VibeCodingPublishServer = require('./vibe-coding/publish-server');// Routesconst authRoutes = require('./auth/routes');@@@ -136,7 +135,6 @@ const themeRoutes = require('./themes/routes');const metaRoutes = require('./meta/routes');const robotStreamerService = require('./integrations/robotstreamer-service');const chatRelayService = require('./integrations/chat-relay-service');--const vibeCodingRoutes = require('./vibe-coding/routes');// Restreamconst restreamRoutes = require('./streaming/restream-routes');@@@ -247,7 +245,6 @@ const observability = require('./web/observability');const { registry: metricsRegistry } = observability.mountMetrics(app, { release });app.use(telemetry.middleware); // request telemetry (openvibe-sdk/service): a no-op until start() calls telemetry.init()if (!drill.enabled) require('./ai/ai-media-analyze').registerMetrics(metricsRegistry);--const vibeCodingPublishServer = new VibeCodingPublishServer(db);function normalizeOrigin(origin) {if (!origin || typeof origin !== 'string') return null;@@@ -380,7 +377,8 @@ app.use(helmet({// request tab was doing for every YouTube link.imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com", "https://openvibe.community"], // paste screenshots (OpenVibe.Community owns pastes)// https://cloudflareinsights.com is where Cloudflare Web Analytics' beacon (script-src static.cloudflareinsights.com) reports.-- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.events", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", "https://cloudflareinsights.com", whipConnectSrc],++ // https://openvibe.chat: the channel chat's feed card asks Chat which feed a slot shows (public/js/chat.js).++ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.chat", "https://openvibe.events", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", "https://cloudflareinsights.com", whipConnectSrc],// VODs/clips play from openvibe.media (the /api proxies 302 there), which may// itself redirect to presigned B2/R2 object-store URLs or, for a B2 copy, to its// origin shield edge.openvibe.media — all must be allowed or the browser blocks@@@ -388,7 +386,8 @@ app.use(helmet({// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser// rejects the element ("no supported source") even though the backend returned audio.mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://edge.openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],-- frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],++ // https://openvibe.chat: the feed card above a channel's chat is Chat's embed (/embed/feed/:id).++ frameSrc: ["'self'", "https://openvibe.network", "https://openvibe.chat", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],workerSrc: ["'self'", "blob:"],scriptSrcAttr: ["'unsafe-inline'"],},@@@ -819,7 +818,11 @@ app.use('/api/media', require('./media/routes'));// Privacy proxy for third-party images (offline-screen HTML, panels): fetched server-side so a// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.app.use('/api/img-proxy', require('./media/external-image-proxy'));--app.use('/api/vibe-coding', vibeCodingRoutes);++// Vibe coding feeds moved to OpenVibe.Chat (2026-10-24, its server/feeds/): the old routes say where they went. A page++// of the previous release still asks for a slot's feed; it gets one that is off, so its widget stays hidden (N-1).++const VIBE_CODING_OFF = Object.freeze({ enabled: 0, widget_title: 'Vibe Coding', viewer_depth: 'standard', show_prompts: 1, show_responses: 1, show_thinking: 0, show_tool_calls: 1, show_tool_arguments: 0, show_file_events: 1, show_file_snippets: 0, redact_file_paths: 1, paused: 0, delay_ms: 0, max_events: 18, max_prompt_chars: 220, max_response_chars: 360, max_thinking_chars: 140, max_tool_chars: 140, max_snippet_chars: 140 });++app.get('/api/vibe-coding/channel/:username/:slot/events', (req, res) => res.json({ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF, publisher: null, events: [], moved_to: 'https://openvibe.chat/feeds' }));++app.use('/api/vibe-coding', (req, res) => res.status(410).json({ error: 'Vibe coding feeds moved to OpenVibe.Chat', moved_to: 'https://openvibe.chat/feeds', api: 'https://openvibe.chat/api/chat/feeds' }));const analyticsRoutes = require('./streaming/analytics-routes');app.use('/api/analytics', analyticsRoutes);const newsRoutes = require('./news/news-routes');@@@ -1210,9 +1213,7 @@ server.on('upgrade', async (req, socket, head) => {} catch (e) { /* non-critical — allow through on DB error */ }// /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.-- if (url.startsWith('/ws/vibe-coding/publish')) {-- vibeCodingPublishServer.handleUpgrade(req, socket, head);-- } else if (url.startsWith('/ws/broadcast')) {++ if (url.startsWith('/ws/broadcast')) {broadcastServer.handleUpgrade(req, socket, head);} else if (url.startsWith('/ws/robotstreamer-publish')) {robotStreamerService.handleUpgrade(req, socket, head);@@@ -1273,7 +1274,6 @@ async function start() {// 3. Initialize chat serverawait chatDelivery.init();-- vibeCodingPublishServer.init(server);// 3b. Initialize breaking news serviceconst newsService = require('./news/news-service');@@@ -5,10 +5,15 @@* A path Live never served, or no longer serves, gets a real HTTP answer instead of a dropped socket: nginx logged* every drop as a 502. Old Pi robot clients still reconnect to /ws/control every few seconds (robots moved to* OpenVibe.Bot, plan T15): 2,137 502s from two of them in three hours on 2026-10-23. They now get a 410 that says-- * where control went, and neither answer costs a ban lookup.++ * where control went, and neither answer costs a ban lookup. The vibe coding publisher socket answers the same way++ * since feeds moved to OpenVibe.Chat.*/--const SERVED = ['/ws/vibe-coding/publish', '/ws/broadcast', '/ws/robotstreamer-publish'];--const GONE = { '/ws/control': 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' };++const SERVED = ['/ws/broadcast', '/ws/robotstreamer-publish'];++const GONE = {++ '/ws/control': 'Robot control moved to OpenVibe.Bot: https://openvibe.bot',++ // Vibe coding feeds moved to OpenVibe.Chat (2026-10-24): a publisher posts to the feed's API there with its key.++ '/ws/vibe-coding/publish': 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds',++};/** 'served' for a path a WebSocket server here answers, else { status, text } to answer with. */function classify(url) {ddeleted file mode 100644@@@ -1,199 +0,0 @@--'use strict';----const WebSocket = require('ws');----const { extractWsToken, authenticateWs } = require('../auth/auth');--const vibeService = require('./service');----// Only the narrow publisher scope. The broad `stream` scope (live state, restreams, VODs, clips)--// used to be accepted too, so any stream-control token could publish a coding feed; a publisher--// token needs vibe_coding_publish (the Dashboard's "GitHub Copilot Companion" preset has it).--// Moving publication onto Network project credentials (surfaced in OpenVibe.Codes) is later work.--const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish']);----function hasVibeCodingPublishScope(user) {-- return !!(user && Array.isArray(user.scopes) && user.scopes.some((scope) => VIBE_CODING_PUBLISH_SCOPES.has(scope)));--}----class VibeCodingPublishServer {-- constructor(db) {-- this.db = db;-- this.wss = null;-- this.clients = new Map();-- }---- init(server) {-- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });-- this.wss.on('connection', (ws, req) => {-- this.handleConnection(ws, req).catch((e) => console.warn('[VibeCoding] connection failed:', e && e.message));-- });-- return this.wss;-- }---- handleUpgrade(req, socket, head) {-- if (!req.url || !req.url.startsWith('/ws/vibe-coding/publish')) {-- return false;-- }-- this.wss.handleUpgrade(req, socket, head, (ws) => {-- this.wss.emit('connection', ws, req);-- });-- return true;-- }---- async handleConnection(ws, req) {-- const token = extractWsToken(req);-- const user = await authenticateWs(token);-- if (!user) {-- ws.close(4401, 'Authentication required');-- return;-- }-- if (user._authSource === 'api_token' && !hasVibeCodingPublishScope(user)) {-- ws.close(4403, 'API token requires vibe_coding_publish scope');-- return;-- }---- const params = new URL(req.url, 'http://localhost').searchParams;-- const managedStreamId = parseInt(params.get('managedStreamId') || '', 10);-- const managedStream = await this.db.getManagedStreamById(managedStreamId);-- if (!Number.isFinite(managedStreamId) || !managedStream || managedStream.user_id !== user.id) {-- ws.close(4404, 'Managed stream not found');-- return;-- }---- const client = {-- user,-- managedStreamId,-- slotSlug: params.get('slotSlug') || managedStream.slug || null,-- sessionKey: null,-- };-- this.clients.set(ws, client);---- const feed = await vibeService.getProjectedViewerFeed(managedStreamId, 1);-- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managedStreamId);-- this.sendTo(ws, {-- type: 'vibe-coding.ready',-- ok: true,-- managed_stream_id: managedStreamId,-- slot_slug: client.slotSlug,-- live_stream_id: liveStream?.id || null,-- settings: feed.settings,-- timestamp: new Date().toISOString(),-- });---- ws.on('message', (data) => {-- this.handleMessage(ws, data).catch((e) => console.warn('[VibeCoding] message failed:', e && e.message));-- });---- ws.on('close', async () => {-- if (client.sessionKey) {-- await vibeService.markSessionEnded(client.managedStreamId, client.sessionKey);-- }-- this.clients.delete(ws);-- });-- }---- async handleMessage(ws, data) {-- const client = this.clients.get(ws);-- if (!client) return;---- let message;-- try {-- message = JSON.parse(data.toString());-- } catch {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'Invalid JSON payload' });-- return;-- }---- if (!message || typeof message !== 'object') {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'Invalid payload' });-- return;-- }---- switch (message.type) {-- case 'vibe-coding.hello':-- client.sessionKey = String(message.sessionKey || '').trim() || null;-- if (!client.sessionKey) {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'sessionKey is required' });-- return;-- }-- await vibeService.upsertVibeCodingSession({-- managedStreamId: client.managedStreamId,-- userId: client.user.id,-- slotSlug: client.slotSlug,-- helloMessage: message,-- });-- this.sendTo(ws, {-- type: 'vibe-coding.hello.ack',-- session_key: client.sessionKey,-- timestamp: new Date().toISOString(),-- });-- return;---- case 'vibe-coding.event':-- if (!message.event || typeof message.event !== 'object') {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'event payload is required' });-- return;-- }-- if (!client.sessionKey) {-- client.sessionKey = String(message.sessionKey || message.event.sessionKey || '').trim() || null;-- }-- if (!client.sessionKey) {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'sessionKey must be established before events are accepted' });-- return;-- }-- await this.processEvent(ws, client, message.event);-- return;---- case 'vibe-coding.ping':-- this.sendTo(ws, { type: 'vibe-coding.pong', timestamp: new Date().toISOString() });-- return;---- default:-- this.sendTo(ws, { type: 'vibe-coding.error', error: `Unsupported message type: ${message.type}` });-- }-- }---- async processEvent(ws, client, event) {-- if (!event.eventId || !event.eventType) {-- this.sendTo(ws, { type: 'vibe-coding.error', error: 'eventId and eventType are required' });-- return;-- }---- const liveStream = await vibeService.getLiveStreamByManagedStreamId(client.managedStreamId);-- await vibeService.storeVibeCodingEvent({-- managedStreamId: client.managedStreamId,-- userId: client.user.id,-- streamId: liveStream?.id || null,-- event,-- });---- const settings = await vibeService.getManagedStreamVibeCodingSettings(client.managedStreamId);-- const projected = vibeService.projectViewerEvent(event, settings);-- if (projected && liveStream?.id) {-- await require('../chat/chat-delivery').event({ kind: 'stream', id: liveStream.id }, {-- type: 'vibe-coding',-- managed_stream_id: client.managedStreamId,-- slot_slug: client.slotSlug,-- delay_ms: settings.delay_ms,-- event: projected,-- }, { key: event.eventId ? `vibe:${client.managedStreamId}:${event.eventId}` : undefined });-- }---- this.sendTo(ws, {-- type: 'vibe-coding.ack',-- event_id: event.eventId,-- live_stream_id: liveStream?.id || null,-- delivered: !!(projected && liveStream?.id),-- timestamp: new Date().toISOString(),-- });-- }---- sendTo(ws, payload) {-- if (ws.readyState === WebSocket.OPEN) {-- ws.send(JSON.stringify(payload));-- }-- }--}----module.exports = VibeCodingPublishServer;--module.exports.hasVibeCodingPublishScope = hasVibeCodingPublishScope;\\ No newline at end of fileddeleted file mode 100644@@@ -1,106 +0,0 @@--'use strict';----const express = require('express');----const db = require('../db/database');--const { requireAuth, optionalAuth } = require('../auth/auth');--const vibeService = require('./service');----const router = express.Router();----function clampLimit(value, fallback = 20) {-- const parsed = parseInt(value, 10);-- if (!Number.isFinite(parsed)) return fallback;-- return Math.min(100, Math.max(1, parsed));--}----router.get('/channel/:username/:slotIdOrSlug/events', optionalAuth, async (req, res) => {-- try {-- const username = String(req.params.username || '').replace(/^@/, '').trim();-- const channel = await db.getChannelByUsername(username);-- if (!channel) return res.status(404).json({ error: 'Channel not found' });---- const managed = await db.getManagedStreamByIdOrSlug(channel.user_id, req.params.slotIdOrSlug);-- if (!managed) return res.status(404).json({ error: 'Managed stream not found' });---- const limit = clampLimit(req.query.limit, 18);-- const feed = await vibeService.getProjectedViewerFeed(managed.id, limit);-- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managed.id);---- res.json({-- managed_stream: {-- id: managed.id,-- slug: managed.slug,-- title: managed.title,-- },-- live_stream_id: liveStream?.id || null,-- settings: feed.settings,-- publisher: feed.publisher,-- events: feed.events,-- });-- } catch (err) {-- console.error('[VibeCoding] Public feed error:', err.message);-- res.status(500).json({ error: 'Failed to load vibe-coding feed' });-- }--});----router.get('/managed/:managedStreamId/events', requireAuth, async (req, res) => {-- try {-- const managedStreamId = parseInt(req.params.managedStreamId, 10);-- const managed = await db.getManagedStreamById(managedStreamId);-- if (!managed || managed.user_id !== req.user.id) {-- return res.status(404).json({ error: 'Managed stream not found' });-- }---- const limit = clampLimit(req.query.limit, 50);-- const rawEvents = await vibeService.getStoredEventsForManagedStream(managedStreamId, limit);-- const feed = await vibeService.getProjectedViewerFeed(managedStreamId, limit);-- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managedStreamId);---- res.json({-- managed_stream_id: managedStreamId,-- live_stream_id: liveStream?.id || null,-- settings: feed.settings,-- publisher: feed.publisher,-- events: rawEvents,-- public_events: feed.events,-- });-- } catch (err) {-- console.error('[VibeCoding] Managed feed error:', err.message);-- res.status(500).json({ error: 'Failed to load vibe-coding events' });-- }--});----router.get('/managed/:managedStreamId/settings', requireAuth, async (req, res) => {-- try {-- const managedStreamId = parseInt(req.params.managedStreamId, 10);-- const managed = await db.getManagedStreamById(managedStreamId);-- if (!managed || managed.user_id !== req.user.id) {-- return res.status(404).json({ error: 'Managed stream not found' });-- }-- res.json({-- managed_stream_id: managedStreamId,-- settings: await vibeService.getManagedStreamVibeCodingSettings(managedStreamId),-- });-- } catch (err) {-- console.error('[VibeCoding] Settings read error:', err.message);-- res.status(500).json({ error: 'Failed to load vibe-coding settings' });-- }--});----router.put('/managed/:managedStreamId/settings', requireAuth, async (req, res) => {-- try {-- const managedStreamId = parseInt(req.params.managedStreamId, 10);-- const managed = await db.getManagedStreamById(managedStreamId);-- if (!managed || managed.user_id !== req.user.id) {-- return res.status(404).json({ error: 'Managed stream not found' });-- }-- const settings = await vibeService.updateManagedStreamVibeCodingSettings(managedStreamId, req.user.id, req.body?.settings || {});-- res.json({ managed_stream_id: managedStreamId, settings });-- } catch (err) {-- console.error('[VibeCoding] Settings save error:', err.message);-- res.status(500).json({ error: 'Failed to save vibe-coding settings' });-- }--});----module.exports = router;\\ No newline at end of fileddeleted file mode 100644@@@ -1,507 +0,0 @@--'use strict';----const path = require('path');----const db = require('../db/database');----const DEFAULT_VIBE_CODING_SETTINGS = Object.freeze({-- enabled: 0,-- widget_title: 'Vibe Coding',-- viewer_depth: 'standard',-- show_prompts: 1,-- show_responses: 1,-- show_thinking: 0,-- show_tool_calls: 1,-- show_tool_arguments: 0,-- show_file_events: 1,-- show_file_snippets: 0,-- redact_file_paths: 1,-- paused: 0,-- delay_ms: 0,-- max_events: 18,-- max_prompt_chars: 220,-- max_response_chars: 360,-- max_thinking_chars: 140,-- max_tool_chars: 140,-- max_snippet_chars: 140,--});----const DEFAULT_PUBLISHER = Object.freeze({-- integrationId: 'custom',-- integrationLabel: 'Custom Publisher',-- vendor: null,-- clientType: 'other',-- clientName: 'Custom Publisher',-- clientVersion: null,-- workspaceName: null,-- machineName: null,-- viewerDepth: DEFAULT_VIBE_CODING_SETTINGS.viewer_depth,-- capabilities: {-- thinking: false,-- toolCalls: false,-- workspaceFileEvents: false,-- },--});----const LEGACY_COPILOT_PUBLISHER = Object.freeze({-- integrationId: 'github-copilot',-- integrationLabel: 'GitHub Copilot',-- vendor: 'GitHub',-- clientType: 'vscode-extension',-- clientName: 'OpenVibe.Live Copilot Companion',--});----function clampInt(value, min, max, fallback) {-- const num = Number(value);-- if (!Number.isFinite(num)) return fallback;-- return Math.min(max, Math.max(min, Math.round(num)));--}----function normalizeBool(value, fallback) {-- if (value === undefined || value === null) return fallback;-- return value ? 1 : 0;--}----function normalizeStringOrNull(value) {-- if (value === undefined || value === null) return null;-- const normalized = String(value).trim();-- return normalized || null;--}----function normalizeDepth(value, fallback = DEFAULT_VIBE_CODING_SETTINGS.viewer_depth) {-- const normalized = String(value || fallback).trim().toLowerCase();-- if (normalized === 'headline' || normalized === 'deep') return normalized;-- return 'standard';--}----function normalizePublisherClientType(value) {-- const normalized = String(value || '').trim().toLowerCase();-- if (normalized === 'vscode-extension' || normalized === 'cli' || normalized === 'browser' || normalized === 'service') {-- return normalized;-- }-- return 'other';--}----function parseJson(value, fallback) {-- if (value === undefined || value === null || value === '') return fallback;-- if (typeof value === 'object') return value;-- try {-- return JSON.parse(value);-- } catch {-- return fallback;-- }--}----function getPublisherFallback(source) {-- if (source && typeof source === 'object' && (source.extensionVersion || source.clientVersion || source.sendsThinking !== undefined || source.sendsToolCalls !== undefined || source.sendsWorkspaceFileEvents !== undefined)) {-- return LEGACY_COPILOT_PUBLISHER;-- }-- return DEFAULT_PUBLISHER;--}----function normalizePublisherDescriptor(value) {-- const source = value && typeof value === 'object' ? value : {};-- const fallback = getPublisherFallback(source);-- const rawCapabilities = parseJson(source.capabilities, {}) || {};---- return {-- integrationId: normalizeStringOrNull(source.integrationId) || fallback.integrationId,-- integrationLabel: normalizeStringOrNull(source.integrationLabel) || normalizeStringOrNull(source.integrationId) || fallback.integrationLabel,-- vendor: normalizeStringOrNull(source.vendor) || fallback.vendor,-- clientType: normalizePublisherClientType(source.clientType || fallback.clientType),-- clientName: normalizeStringOrNull(source.clientName) || normalizeStringOrNull(source.integrationLabel) || fallback.clientName,-- clientVersion: normalizeStringOrNull(source.clientVersion || source.extensionVersion),-- workspaceName: normalizeStringOrNull(source.workspaceName),-- machineName: normalizeStringOrNull(source.machineName),-- viewerDepth: normalizeDepth(source.viewerDepth),-- capabilities: {-- thinking: rawCapabilities.thinking !== undefined ? !!rawCapabilities.thinking : !!source.sendsThinking,-- toolCalls: rawCapabilities.toolCalls !== undefined ? !!rawCapabilities.toolCalls : !!source.sendsToolCalls,-- workspaceFileEvents: rawCapabilities.workspaceFileEvents !== undefined ? !!rawCapabilities.workspaceFileEvents : !!source.sendsWorkspaceFileEvents,-- },-- };--}----function normalizeEventPublisher(value) {-- if (!value || typeof value !== 'object') return null;-- const publisher = normalizePublisherDescriptor(value);-- return {-- integrationId: publisher.integrationId,-- integrationLabel: publisher.integrationLabel,-- vendor: publisher.vendor,-- clientType: publisher.clientType,-- clientName: publisher.clientName,-- };--}----function normalizeVibeCodingSettings(value) {-- const source = value && typeof value === 'object' ? value : {};-- return {-- enabled: normalizeBool(source.enabled, DEFAULT_VIBE_CODING_SETTINGS.enabled),-- widget_title: String(source.widget_title || DEFAULT_VIBE_CODING_SETTINGS.widget_title).trim() || DEFAULT_VIBE_CODING_SETTINGS.widget_title,-- viewer_depth: normalizeDepth(source.viewer_depth),-- show_prompts: normalizeBool(source.show_prompts, DEFAULT_VIBE_CODING_SETTINGS.show_prompts),-- show_responses: normalizeBool(source.show_responses, DEFAULT_VIBE_CODING_SETTINGS.show_responses),-- show_thinking: normalizeBool(source.show_thinking, DEFAULT_VIBE_CODING_SETTINGS.show_thinking),-- show_tool_calls: normalizeBool(source.show_tool_calls, DEFAULT_VIBE_CODING_SETTINGS.show_tool_calls),-- show_tool_arguments: normalizeBool(source.show_tool_arguments, DEFAULT_VIBE_CODING_SETTINGS.show_tool_arguments),-- show_file_events: normalizeBool(source.show_file_events, DEFAULT_VIBE_CODING_SETTINGS.show_file_events),-- show_file_snippets: normalizeBool(source.show_file_snippets, DEFAULT_VIBE_CODING_SETTINGS.show_file_snippets),-- redact_file_paths: normalizeBool(source.redact_file_paths, DEFAULT_VIBE_CODING_SETTINGS.redact_file_paths),-- paused: normalizeBool(source.paused, DEFAULT_VIBE_CODING_SETTINGS.paused),-- delay_ms: clampInt(source.delay_ms, 0, 120000, DEFAULT_VIBE_CODING_SETTINGS.delay_ms),-- max_events: clampInt(source.max_events, 1, 100, DEFAULT_VIBE_CODING_SETTINGS.max_events),-- max_prompt_chars: clampInt(source.max_prompt_chars, 60, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_prompt_chars),-- max_response_chars: clampInt(source.max_response_chars, 80, 2400, DEFAULT_VIBE_CODING_SETTINGS.max_response_chars),-- max_thinking_chars: clampInt(source.max_thinking_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_thinking_chars),-- max_tool_chars: clampInt(source.max_tool_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_tool_chars),-- max_snippet_chars: clampInt(source.max_snippet_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_snippet_chars),-- };--}----function parseBroadcastSettings(json) {-- if (!json) return {};-- if (typeof json === 'object') return json;-- try {-- return JSON.parse(json);-- } catch {-- return {};-- }--}----function getStoredVibeCodingSettings(broadcastSettings) {-- if (!broadcastSettings || typeof broadcastSettings !== 'object') return null;-- if (broadcastSettings.vibe_coding && typeof broadcastSettings.vibe_coding === 'object') {-- return broadcastSettings.vibe_coding;-- }-- if (broadcastSettings.vibeCoding && typeof broadcastSettings.vibeCoding === 'object') {-- return broadcastSettings.vibeCoding;-- }-- return null;--}----function setStoredVibeCodingSettings(broadcastSettings, settings) {-- if (!broadcastSettings || typeof broadcastSettings !== 'object') return;-- broadcastSettings.vibe_coding = settings;-- broadcastSettings.vibeCoding = settings;--}----async function getManagedStreamRow(managedStreamId) {-- return await db.get(-- `SELECT ms.*, u.username, u.display_name, u.avatar_url, u.profile_color-- FROM managed_streams ms-- JOIN users u ON ms.user_id = u.id-- WHERE ms.id = ?`,-- [managedStreamId]-- );--}----async function getManagedStreamVibeCodingSettings(managedStreamId) {-- const row = await getManagedStreamRow(managedStreamId);-- if (!row) return normalizeVibeCodingSettings();-- const broadcastSettings = parseBroadcastSettings(row.broadcast_settings);-- return normalizeVibeCodingSettings(getStoredVibeCodingSettings(broadcastSettings));--}----async function updateManagedStreamVibeCodingSettings(managedStreamId, userId, partialSettings) {-- const row = await db.get('SELECT broadcast_settings FROM managed_streams WHERE id = ? AND user_id = ?', [managedStreamId, userId]);-- if (!row) return null;-- const broadcastSettings = parseBroadcastSettings(row.broadcast_settings);-- const nextSettings = normalizeVibeCodingSettings({-- ...getStoredVibeCodingSettings(broadcastSettings),-- ...(partialSettings || {}),-- });-- setStoredVibeCodingSettings(broadcastSettings, nextSettings);-- await db.updateManagedStreamBroadcastSettings(managedStreamId, userId, broadcastSettings);-- return nextSettings;--}----async function getLiveStreamByManagedStreamId(managedStreamId) {-- return await db.get(-- `SELECT s.*, ms.slug AS managed_stream_slug-- FROM streams s-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id-- WHERE s.managed_stream_id = ? AND s.is_live = 1-- ORDER BY COALESCE(s.started_at, s.created_at) DESC-- LIMIT 1`,-- [managedStreamId]-- );--}----async function upsertVibeCodingSession({ managedStreamId, userId, slotSlug, helloMessage }) {-- const publisher = normalizePublisherDescriptor(helloMessage?.publisher);-- await db.run(-- `INSERT INTO vibe_coding_sessions (-- managed_stream_id, user_id, session_key, slot_slug, workspace_name,-- machine_name, extension_version, publisher_id, publisher_label,-- publisher_vendor, publisher_client_type, publisher_client_name,-- publisher_client_version, publisher_capabilities_json,-- publisher_depth, status, last_event_at-- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ov_now())-- ON CONFLICT(managed_stream_id, session_key) DO UPDATE SET-- workspace_name = excluded.workspace_name,-- machine_name = excluded.machine_name,-- extension_version = excluded.extension_version,-- publisher_id = excluded.publisher_id,-- publisher_label = excluded.publisher_label,-- publisher_vendor = excluded.publisher_vendor,-- publisher_client_type = excluded.publisher_client_type,-- publisher_client_name = excluded.publisher_client_name,-- publisher_client_version = excluded.publisher_client_version,-- publisher_capabilities_json = excluded.publisher_capabilities_json,-- publisher_depth = excluded.publisher_depth,-- slot_slug = excluded.slot_slug,-- status = 'active',-- ended_at = NULL,-- last_event_at = ov_now()`,-- [-- managedStreamId,-- userId,-- String(helloMessage.sessionKey || ''),-- slotSlug || null,-- publisher.workspaceName,-- publisher.machineName,-- publisher.clientVersion,-- publisher.integrationId,-- publisher.integrationLabel,-- publisher.vendor,-- publisher.clientType,-- publisher.clientName,-- publisher.clientVersion,-- JSON.stringify(publisher.capabilities),-- publisher.viewerDepth,-- ]-- );--}----async function getLatestPublisherForManagedStream(managedStreamId) {-- const row = await db.get(-- `SELECT workspace_name, machine_name, extension_version, publisher_id,-- publisher_label, publisher_vendor, publisher_client_type,-- publisher_client_name, publisher_client_version,-- publisher_capabilities_json, publisher_depth-- FROM vibe_coding_sessions-- WHERE managed_stream_id = ?-- ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC-- LIMIT 1`,-- [managedStreamId]-- );-- if (!row) return null;-- return normalizePublisherDescriptor({-- integrationId: row.publisher_id,-- integrationLabel: row.publisher_label,-- vendor: row.publisher_vendor,-- clientType: row.publisher_client_type,-- clientName: row.publisher_client_name,-- clientVersion: row.publisher_client_version || row.extension_version,-- workspaceName: row.workspace_name,-- machineName: row.machine_name,-- viewerDepth: row.publisher_depth,-- capabilities: parseJson(row.publisher_capabilities_json, {}),-- });--}----async function markSessionEnded(managedStreamId, sessionKey) {-- await db.run(-- `UPDATE vibe_coding_sessions-- SET status = 'ended', ended_at = ov_now(), last_event_at = ov_now()-- WHERE managed_stream_id = ? AND session_key = ?`,-- [managedStreamId, sessionKey]-- );--}----async function storeVibeCodingEvent({ managedStreamId, userId, streamId, event }) {-- await db.run(-- `INSERT INTO vibe_coding_events (-- managed_stream_id, user_id, stream_id, session_key, event_id,-- sequence_num, event_type, visibility, depth, summary, payload_json-- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(managed_stream_id, event_id) DO UPDATE SET-- stream_id = excluded.stream_id,-- sequence_num = excluded.sequence_num,-- event_type = excluded.event_type,-- visibility = excluded.visibility,-- depth = excluded.depth,-- summary = excluded.summary,-- payload_json = excluded.payload_json,-- created_at = ov_now()`,-- [-- managedStreamId,-- userId,-- streamId || null,-- event.sessionKey || null,-- event.eventId,-- Number(event.sequence || 0),-- String(event.eventType || 'session.status'),-- String(event.visibility || 'public'),-- String(event.depth || 'standard'),-- String(event.summary || ''),-- JSON.stringify(event),-- ]-- );-- await db.run(-- `UPDATE vibe_coding_sessions-- SET last_event_at = ov_now()-- WHERE managed_stream_id = ? AND session_key = ?`,-- [managedStreamId, event.sessionKey || null]-- );--}----async function getStoredEventsForManagedStream(managedStreamId, limit = DEFAULT_VIBE_CODING_SETTINGS.max_events) {-- const rows = await db.all(-- `SELECT payload_json, created_at-- FROM vibe_coding_events-- WHERE managed_stream_id = ?-- ORDER BY id DESC-- LIMIT ?`,-- [managedStreamId, clampInt(limit, 1, 100, DEFAULT_VIBE_CODING_SETTINGS.max_events)]-- );---- return rows.reverse().map((row) => {-- try {-- return JSON.parse(row.payload_json);-- } catch {-- return null;-- }-- }).filter(Boolean);--}----function truncateText(value, maxChars, depth) {-- const text = String(value || '').replace(/\s+/g, ' ').trim();-- if (!text) return '';-- let limit = maxChars;-- if (depth === 'headline') {-- limit = Math.max(40, Math.floor(maxChars * 0.6));-- } else if (depth === 'deep') {-- limit = Math.max(maxChars, Math.floor(maxChars * 1.1));-- }-- if (text.length <= limit) return text;-- return `${text.slice(0, Math.max(20, limit - 1)).trim()}…`;--}----function shouldExposePublicToolPreview(toolName, settings) {-- if (settings?.show_tool_arguments) return true;-- const normalized = String(toolName || '').trim().toLowerCase();-- return normalized === 'run_in_terminal'-- || normalized === 'create_and_run_task'-- || normalized === 'run_notebook_cell';--}----function projectViewerEvent(event, settings) {-- if (!event || !settings || !settings.enabled || settings.paused) {-- return null;-- }---- const projected = {-- eventId: event.eventId,-- eventType: event.eventType,-- occurredAt: event.occurredAt,-- sequence: event.sequence,-- depth: settings.viewer_depth,-- summary: '',-- source: event.source || null,-- prompt: null,-- response: null,-- thinking: null,-- tool: null,-- file: null,-- publisher: normalizeEventPublisher(event?.publisher),-- session: event.session || null,-- metadata: {-- requestId: event?.metadata?.requestId || null,-- responseId: event?.metadata?.responseId || null,-- workspaceName: event?.metadata?.workspaceName || null,-- model: event?.metadata?.model || null,-- },-- };---- if (event.eventType === 'session.status') {-- projected.summary = truncateText(event.summary, settings.max_prompt_chars, settings.viewer_depth);-- return projected;-- }---- if (event.eventType === 'prompt') {-- if (!settings.show_prompts) return null;-- const text = truncateText(event?.prompt?.text || event.summary, settings.max_prompt_chars, settings.viewer_depth);-- projected.summary = text;-- projected.prompt = { text };-- return projected;-- }---- if (event.eventType === 'response') {-- if (!settings.show_responses) return null;-- const text = truncateText(event?.response?.text || event.summary, settings.max_response_chars, settings.viewer_depth);-- projected.summary = text;-- projected.response = { text };-- return projected;-- }---- if (event.eventType === 'thinking') {-- if (!settings.show_thinking) return null;-- const text = truncateText(event?.thinking?.text || event.summary, settings.max_thinking_chars, settings.viewer_depth);-- projected.summary = text;-- projected.thinking = { text };-- return projected;-- }---- if (event.eventType === 'tool.call') {-- if (!settings.show_tool_calls) return null;-- const includeToolPreview = shouldExposePublicToolPreview(event?.tool?.name, settings);-- projected.summary = truncateText(event.summary || `Used ${event?.tool?.name || 'tool'}`, settings.max_tool_chars, settings.viewer_depth);-- projected.tool = {-- name: event?.tool?.name || 'tool',-- phase: event?.tool?.phase || 'completed',-- argumentsPreview: includeToolPreview ? truncateText(event?.tool?.argumentsPreview || '', settings.max_tool_chars, settings.viewer_depth) : null,-- resultPreview: includeToolPreview ? truncateText(event?.tool?.resultPreview || '', settings.max_tool_chars, settings.viewer_depth) : null,-- };-- return projected;-- }---- if (event.eventType === 'file.change' || event.eventType === 'file.save') {-- if (!settings.show_file_events) return null;-- const rawPath = String(event?.file?.relativePath || event?.file?.name || '').trim();-- const displayPath = settings.redact_file_paths ? path.posix.basename(rawPath.replace(/\\/g, '/')) : rawPath;-- projected.summary = event.eventType === 'file.save'-- ? `Saved ${displayPath || event?.file?.name || 'file'}`-- : `Editing ${displayPath || event?.file?.name || 'file'}`;-- projected.file = {-- name: event?.file?.name || displayPath || 'file',-- relativePath: displayPath || null,-- operation: event?.file?.operation || (event.eventType === 'file.save' ? 'save' : 'edit'),-- changeCount: Number(event?.file?.changeCount || 0),-- snippet: settings.show_file_snippets ? truncateText(event?.file?.snippet || '', settings.max_snippet_chars, settings.viewer_depth) : null,-- };-- return projected;-- }---- return null;--}----async function getProjectedViewerFeed(managedStreamId, limit) {-- const settings = await getManagedStreamVibeCodingSettings(managedStreamId);-- const events = (await getStoredEventsForManagedStream(managedStreamId, limit || settings.max_events))-- .map((event) => projectViewerEvent(event, settings))-- .filter(Boolean);-- return {-- settings,-- publisher: await getLatestPublisherForManagedStream(managedStreamId),-- events,-- };--}----module.exports = {-- DEFAULT_VIBE_CODING_SETTINGS,-- normalizeVibeCodingSettings,-- normalizePublisherDescriptor,-- getManagedStreamRow,-- getManagedStreamVibeCodingSettings,-- updateManagedStreamVibeCodingSettings,-- getLiveStreamByManagedStreamId,-- getLatestPublisherForManagedStream,-- upsertVibeCodingSession,-- markSessionEnded,-- storeVibeCodingEvent,-- getStoredEventsForManagedStream,-- projectViewerEvent,-- getProjectedViewerFeed,--};\\ No newline at end of file@@@ -44,7 +44,7 @@ function load({ streaming = false } = {}) {isStreaming: () => streaming,getChatRenderTargetId: () => 'chat-messages',getChatEl: () => ({ messages: null }),-- refreshVibeWidgetFeed: () => Promise.resolve(),++ refreshFeedCard: () => Promise.resolve(),hydrateActiveChatHistory: () => Promise.resolve(),CHAT_RECONNECT_BASE: 1000,console,@@@ -9,7 +9,7 @@* recut, trim, bulk actions (which take a list of ids), thumbnail regeneration, and uploading* recording chunks / a live thumbnail into someone else's stream;* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the-- * answer would be the new key), the slot's vibe-coding settings;++ * answer would be the new key) (a slot's vibe coding feed is OpenVibe.Chat's since 2026-10-24);* - restream destinations (each holds a platform stream key): edit, delete, start, stop;* - API tokens.*@@@ -88,7 +88,6 @@ app.use('/api/restream', require('../server/streaming/restream-routes'));app.use('/api/vods', require('../server/media-proxy/vods'));app.use('/api/clips', require('../server/media-proxy/clips'));app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));--app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));const server = http.createServer(app).listen(0, '127.0.0.1');let ipSeq = 0;@@@ -239,12 +238,6 @@ async function check(name, fn) {assert.strictEqual((await db.getUserById(ALICE)).stream_key, '3'.repeat(32));assert.strictEqual((await row('managed_streams', slotA)).stream_key, SLOT_KEY);});-- await check('vibe-coding: Bob cannot change Alice\'s slot settings', async () => {-- refused(await call('PUT', `/api/vibe-coding/managed/${slotA}/settings`, BOB, { enabled: true, repo: 'bob/pwn' }), 'PUT settings');-- refused(await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, BOB), 'GET settings');-- refused(await call('GET', `/api/vibe-coding/managed/${slotA}/events`, BOB), 'GET events');-- assert.strictEqual((await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, ALICE)).status, 200, 'control: Alice reads hers');-- });await check('control: Alice can edit her own slot', async () => {const r = await call('PUT', `/api/streams/managed/${slotA}`, ALICE, { title: 'Alice main' });assert.strictEqual(r.status, 200, r.text.slice(0, 200));nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++// Vibe coding feeds are OpenVibe.Chat's since 2026-10-24 (its server/feeds/, openvibe.chat/feeds; the owner: "migrate++// that whole entire feature so it's built into the openvibe.chat system"). Nothing under server/ or scripts/ reads or++// writes Live's vibe_coding_sessions or vibe_coding_events (Chat's scripts/import-live-feeds.js copies them; a contract++// migration drops them once the copy is verified), the publisher socket and the old routes answer 410 naming Chat, and++// the channel chat shows Chat's own card instead of Live's widget.++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const NAMES = /\b(vibe_coding_sessions|vibe_coding_events)\b/;++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);++++function walk(dir, out = []) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);++ }++ return out;++}++++const offenders = [];++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {++ if (!isCommentLine(line) && NAMES.test(line)) offenders.push(`${path.relative(ROOT, file)}:${i + 1}: ${line.trim().slice(0, 120)}`);++ });++}++assert.deepStrictEqual(offenders, [], 'vibe coding feeds are OpenVibe.Chat\'s: nothing here reads Live\'s old tables');++assert.ok(!fs.existsSync(path.join(ROOT, 'server/vibe-coding')), 'Live runs no feed server');++++const { classify } = require('../server/net/ws-paths');++assert.deepStrictEqual(classify('/ws/vibe-coding/publish?token=x'), { status: '410 Gone', text: 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds' });++++const index = fs.readFileSync(path.join(ROOT, 'server/index.js'), 'utf8');++assert.match(index, /app\.use\('\/api\/vibe-coding', \(req, res\) => res\.status\(410\)/, 'the old routes say where feeds went');++assert.match(index, /app\.get\('\/api\/vibe-coding\/channel\/:username\/:slot\/events', \(req, res\) => res\.json\(\{ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF,/, 'an N-1 page gets a feed that is off');++assert.match(index, /const VIBE_CODING_OFF = Object\.freeze\(\{ enabled: 0,/);++assert.match(index, /frameSrc: \[[^\]]*"https:\/\/openvibe\.chat"/, 'the card frames Chat\'s embed');++assert.match(index, /connectSrc: \[[^\]]*"https:\/\/openvibe\.chat"/, 'and asks Chat which feed a slot shows');++++const chat = fs.readFileSync(path.join(ROOT, 'public/js/chat.js'), 'utf8');++assert.ok(!/_vibeWidgetState|refreshVibeWidgetFeed|\/vibe-coding\/channel\//.test(chat), 'Live\'s own widget is gone');++assert.match(chat, /\$\{FEED_ORIGIN\}\/api\/chat\/feeds\?slot=/);++assert.match(chat, /\$\{FEED_ORIGIN\}\/embed\/feed\//);++assert.match(chat, /e\.origin !== FEED_ORIGIN/, 'the card\'s height comes only from Chat\'s frame');++console.log('vibe coding: Chat\'s; nothing reads Live\'s old tables, the old socket and routes answer 410, the channel shows Chat\'s card');ddeleted file mode 100644@@@ -1,78 +0,0 @@--/**-- * Vibe-coding publication needs the narrow `vibe_coding_publish` scope.-- *-- * /ws/vibe-coding/publish and the /api/vibe-coding writes used to accept the broad `stream` scope-- * too, so any stream-control token (live state, restreams, VODs, clips) could publish a coding feed.-- * Now an hbt_ token with `stream` alone is closed with 4403 / refused, and a token with-- * `vibe_coding_publish` (the "GitHub Copilot Companion" preset) keeps working. Session sign-in is-- * unaffected. (Moving publication to Network project credentials in OpenVibe.Codes is later work.)-- *-- * node test/vibe-coding-scope.test.js-- */--'use strict';--const assert = require('assert');--const http = require('http');----process.env.NODE_ENV = 'test';--const quiet = console.log;--console.log = () => {};--console.warn = () => {};----const db = require('../server/db/database');--const auth = require('../server/auth/auth');--const WebSocket = require('ws');--const VibeCodingPublishServer = require('../server/vibe-coding/publish-server');----let slot;----/** Connect and return how the socket ended up: { code } when closed, or { ready } on vibe-coding.ready. */--function connect(port, token) {-- return new Promise((resolve) => {-- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/vibe-coding/publish?token=${token}&managedStreamId=${slot}&slotSlug=camp-code`);-- ws.on('message', (data) => {-- const msg = JSON.parse(String(data));-- if (msg.type === 'vibe-coding.ready') { ws.close(); resolve({ ready: true }); }-- });-- ws.on('close', (code) => resolve({ code }));-- ws.on('error', () => {});-- });--}----(async () => {-- await db.initDb();-- await db.getDb().prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE VALUES (7, 'coder', 'coder', 'c@x', 'x', 'streamer')`).run();-- await db.createManagedStream({ user_id: 7, slug: 'camp-code', title: 'code', stream_key: 'key-code' });-- slot = Number((await db.get('SELECT id FROM managed_streams WHERE user_id = 7 AND slug = ?', ['camp-code'])).id);-- const narrow = (await db.createApiToken(7, 'Copilot Companion', ['read', 'vibe_coding_publish'])).token;-- const broad = (await db.createApiToken(7, 'Stream Controller', ['read', 'stream', 'control'])).token;---- // Scope check itself.-- const { hasVibeCodingPublishScope } = VibeCodingPublishServer;-- assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['vibe_coding_publish'] }), true);-- assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');---- // The real WebSocket endpoint with real hbt_ tokens.-- const pub = new VibeCodingPublishServer(db);-- const server = http.createServer();-- pub.init(server);-- server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });-- await new Promise((r) => server.listen(0, '127.0.0.1', r));-- const port = server.address().port;-- assert.deepStrictEqual(await connect(port, broad), { code: 4403 }, 'a stream-scoped token is closed with 4403');-- assert.deepStrictEqual(await connect(port, narrow), { ready: true }, 'a vibe_coding_publish token still publishes');-- assert.deepStrictEqual(await connect(port, 'hbt_' + '0'.repeat(64)), { code: 4401 }, 'an unknown token is refused');-- server.close();---- // REST: the /api/vibe-coding writes take only the narrow scope.-- const t = (method, url, scopes) => auth.apiTokenAllows({ method, originalUrl: url }, scopes);-- assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'stream', 'control']), false, 'stream no longer writes vibe-coding settings');-- assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'vibe_coding_publish']), true);-- assert.strictEqual(t('GET', `/api/vibe-coding/managed/${slot}/events`, ['read']), true, 'reads are unchanged');-- assert.strictEqual(t('POST', '/api/streams/managed', ['stream']), true, 'the stream scope still covers stream control');---- quiet('vibe-coding-scope: ok');-- process.exit(0);--})().catch((err) => {-- quiet(err);-- process.exit(1);--});@@@ -12,7 +12,7 @@ const { classify, refuse, SERVED } = require('../server/net/ws-paths');(async () => {assert.strictEqual(classify('/ws/broadcast?stream=1'), 'served');-- assert.strictEqual(classify('/ws/vibe-coding/publish'), 'served');++ assert.deepStrictEqual(classify('/ws/vibe-coding/publish?token=x'), { status: '410 Gone', text: 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds' });assert.strictEqual(classify('/ws/robotstreamer-publish?key=x'), 'served');assert.deepStrictEqual(classify('/ws/control'), { status: '410 Gone', text: 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' });assert.deepStrictEqual(classify('/ws/control?robot=7'), classify('/ws/control'));
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
## Features
## Features
- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS
- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS
-- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed
+- [Vibe Coding](vibe-coding.md) — moved to OpenVibe Chat feeds (openvibe.chat/feeds); what Live answers now
- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation
- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation
- [API Tokens](api-tokens.md) — Bot/integration authentication
- [API Tokens](api-tokens.md) — Bot/integration authentication
- [The Rundown](rundown.md) — What streamers talked about, written up: topics, takes in their own words, episodes (replaced the Arena, 2026-10-19)
- [The Rundown](rundown.md) — What streamers talked about, written up: topics, takes in their own words, episodes (replaced the Arena, 2026-10-19)
@@ -8,7 +8,7 @@ API tokens provide programmatic access to OpenVibe.Live for bots, integrations,
@@ -8,7 +8,7 @@ API tokens provide programmatic access to OpenVibe.Live for bots, integrations,
- Each user can create up to **10 active tokens**
- Each user can create up to **10 active tokens**
- Tokens are hashed with SHA-256 before storage — the raw token is shown only once at creation
- Tokens are hashed with SHA-256 before storage — the raw token is shown only once at creation
- Tokens support scoped permissions and optional expiration
- Tokens support scoped permissions and optional expiration
-- The dashboard exposes integration presets, including a dedicated vibe-coding publisher preset
+- The dashboard exposes integration presets
## Creating a Token
## Creating a Token
@@ -47,12 +47,10 @@ Response:
@@ -47,12 +47,10 @@ Response:
| `read` | Read streams, VODs, user info |
| `read` | Read streams, VODs, user info |
| `stream` | Start/stop streams, update stream info |
| `stream` | Start/stop streams, update stream info |
| `control` | Hardware control bridge access |
| `control` | Hardware control bridge access |
-| `vibe_coding_publish` | Publish sanitized coding-feed events to `/ws/vibe-coding/publish` |
## Recommended Presets
## Recommended Presets
- `Chat Bot` → `chat`, `read`
- `Chat Bot` → `chat`, `read`
-- `GitHub Copilot Companion` → `read`, `vibe_coding_publish`
- `Stream Controller` → `read`, `stream`, `control`
- `Stream Controller` → `read`, `stream`, `control`
## Using a Token
## Using a Token
@@ -68,7 +66,7 @@ curl https://openvibe.live/api/streams \
@@ -68,7 +66,7 @@ curl https://openvibe.live/api/streams \
### Vibe Coding Publisher
### Vibe Coding Publisher
-For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with the `vibe_coding_publish` scope. It is the only scope `/ws/vibe-coding/publish` and the `/api/vibe-coding` writes accept; a `stream` token is refused there.
+Vibe coding feeds moved to OpenVibe Chat on 2026-10-24 ([docs/vibe-coding.md](vibe-coding.md)). A feed has its own key from [openvibe.chat/feeds](https://openvibe.chat/feeds), so Live API tokens no longer publish there.
### WebSocket (Chat)
### WebSocket (Chat)
@@ -137,7 +135,6 @@ ws.on('message', (data) => {
@@ -137,7 +135,6 @@ ws.on('message', (data) => {
- Treat API tokens like passwords — never commit them to source control
- Treat API tokens like passwords — never commit them to source control
- Use the narrowest scope necessary for your use case
- Use the narrowest scope necessary for your use case
-- Prefer `vibe_coding_publish` over `stream` for coding-feed publishers so tokens stay least-privilege
- Set an expiration for tokens used in shared environments
- Set an expiration for tokens used in shared environments
- Revoke tokens immediately if compromised
- Revoke tokens immediately if compromised
- The `last_used_at` field in the token list helps identify unused tokens for cleanup
- The `last_used_at` field in the token list helps identify unused tokens for cleanup
@@ -10,7 +10,7 @@ Your RTMP stream key is shown (hidden by default) for use with OBS, Streamlabs,
@@ -10,7 +10,7 @@ Your RTMP stream key is shown (hidden by default) for use with OBS, Streamlabs,
Create long-lived API tokens for bots and integrations. Tokens authenticate via the `Authorization: Bearer hbt_...` header for REST API and chat WebSocket connections.
Create long-lived API tokens for bots and integrations. Tokens authenticate via the `Authorization: Bearer hbt_...` header for REST API and chat WebSocket connections.
-Available scopes: `chat`, `read`, `stream`, `vibe_coding_publish`.
+Available scopes: `chat`, `read`, `stream`. (A vibe coding feed has its own key on openvibe.chat/feeds since 2026-10-24.)
See [API Tokens](api-tokens.md) for full details.
See [API Tokens](api-tokens.md) for full details.
@@ -1,283 +1,21 @@
@@ -1,283 +1,21 @@
# Vibe Coding
# Vibe Coding
-OpenVibe.Live's vibe-coding feature lets a streamer publish a sanitized coding activity feed from an editor extension, CLI tool, agent, or other publisher into a managed stream slot. Viewers see a compact widget above chat, and the slot owner controls depth, visibility, and delay per slot.
+Vibe coding feeds moved to **OpenVibe Chat** on 2026-10-24: make one at [openvibe.chat/feeds](https://openvibe.chat/feeds). A feed shows what you are building, live: Claude Code, GitHub Copilot or any tool sends each step (a prompt, a command, a file edited, the answer), and viewers follow along in a card above your channel's chat while you stream, in a Chat room, or embedded on your own site.
-GitHub Copilot is the reference publisher we provide today, but the protocol is intentionally provider-agnostic so other integrations such as Claude Code or custom tooling can emit the same canonical event model.
+## What changed for streamers
-## Data Model
+- **Set up on Chat.** Make a feed on openvibe.chat/feeds and choose the channel slot it shows on. The card appears above that slot's chat whenever the slot is live. Live's stream workspace links there.
+- **Claude Code needs no extension.** The feed page gives a `.claude/settings.json` block of Claude Code `http` hooks. Each prompt, tool use and answer is posted with your feed's key, and the hooks never block Claude Code.
+- **Any other tool** posts the same canonical events (`session.status`, `prompt`, `response`, `thinking`, `tool.call`, `file.change`, `file.save`) to `POST https://openvibe.chat/api/chat/feeds/<feed>/events` with `Authorization: Bearer <feed key>`. Live's old `vibe-coding.event` envelopes are accepted as they are.
+- **Safer by default.** Every step is scrubbed before it is stored: API keys and tokens, passwords, private keys, and your home folder. Viewers see only what your settings allow: prompts, answers, tools, what the tools were given, files, and thinking. The delay is held on the server, so nobody sees a step early.
-Persistent slot settings live in `managed_streams.broadcast_settings.vibe_coding` and are normalized to this shape:
+## What Live answers now
-```json
-{
- "enabled": 1,
- "widget_title": "Vibe Coding",
- "viewer_depth": "standard",
- "show_prompts": 1,
- "show_responses": 1,
- "show_thinking": 0,
- "show_tool_calls": 1,
- "show_tool_arguments": 0,
- "show_file_events": 1,
- "show_file_snippets": 0,
- "redact_file_paths": 1,
- "paused": 0,
- "delay_ms": 0,
- "max_events": 18,
- "max_prompt_chars": 220,
- "max_response_chars": 360,
- "max_thinking_chars": 140,
- "max_tool_chars": 140,
- "max_snippet_chars": 140
-}
-```
+- `wss://openvibe.live/ws/vibe-coding/publish` answers **410**, naming openvibe.chat/feeds.
+- `/api/vibe-coding/*` answers **410** with `moved_to` and `api` links. For one release, a page of the previous release that asks `GET /api/vibe-coding/channel/:username/:slot/events` gets a feed that is switched off, so its widget stays hidden.
+- API tokens no longer offer the `vibe_coding_publish` scope or the "GitHub Copilot Companion" preset. A feed has its own key.
+- The channel chat frames Chat's card (`https://openvibe.chat/embed/feed/<feed>`). It finds the card by asking Chat which feed a slot shows: `GET https://openvibe.chat/api/chat/feeds?slot=<managed stream id>`.
-Durable runtime data is stored in two PostgreSQL tables:
+Live's old `vibe_coding_sessions` and `vibe_coding_events` were copied to Chat and verified (Chat's `scripts/import-live-feeds.js`), each slot keeping its newest 5,000 events. Nothing in Live reads them any more (`test/vibe-coding-moved.test.js`), and a contract migration drops them.
-- `vibe_coding_sessions` — one row per `managed_stream_id + session_key`
-- `vibe_coding_events` — canonical sanitized events keyed by `managed_stream_id + event_id`
-
-## Publisher WebSocket
-
-Streamers publish live coding activity over a dedicated WebSocket endpoint:
-
-```text
-GET /ws/vibe-coding/publish?token=hbt_xxx&managedStreamId=123&slotSlug=camp-code
-```
-
-Authentication accepts JWTs or `hbt_` API tokens. An API token needs the dedicated `vibe_coding_publish` scope (the Dashboard's **GitHub Copilot Companion** preset); the broad `stream` scope is not accepted any more (close code `4403`). The same holds for the REST settings routes under `/api/vibe-coding`.
-
-### Server messages
-
-Ready:
-
-```json
-{
- "type": "vibe-coding.ready",
- "ok": true,
- "managed_stream_id": 123,
- "slot_slug": "camp-code",
- "live_stream_id": 456,
- "settings": {
- "enabled": 1,
- "viewer_depth": "standard"
- },
- "timestamp": "2026-04-21T23:10:00.000Z"
-}
-```
-
-Hello ack:
-
-```json
-{
- "type": "vibe-coding.hello.ack",
- "session_key": "openvibe-20260421231000-ab12cd34",
- "timestamp": "2026-04-21T23:10:00.250Z"
-}
-```
-
-Event ack:
-
-```json
-{
- "type": "vibe-coding.ack",
- "event_id": "28c49b5c-9070-4fd3-b750-b67d84338f64",
- "live_stream_id": 456,
- "delivered": true,
- "timestamp": "2026-04-21T23:10:01.500Z"
-}
-```
-
-Error:
-
-```json
-{
- "type": "vibe-coding.error",
- "error": "API token requires vibe_coding_publish scope"
-}
-```
-
-### Client messages
-
-Hello:
-
-```json
-{
- "type": "vibe-coding.hello",
- "protocolVersion": 1,
- "managedStreamId": 123,
- "slotSlug": "camp-code",
- "timestamp": "2026-04-21T23:10:00.000Z",
- "sessionKey": "openvibe-20260421231000-ab12cd34",
- "publisher": {
- "integrationId": "github-copilot",
- "integrationLabel": "GitHub Copilot",
- "vendor": "GitHub",
- "clientType": "vscode-extension",
- "clientName": "OpenVibe.Live Copilot Companion",
- "clientVersion": "0.0.1",
- "workspaceName": "openvibe",
- "machineName": "streamer-laptop",
- "viewerDepth": "standard",
- "capabilities": {
- "thinking": false,
- "toolCalls": true,
- "workspaceFileEvents": true
- }
- }
-}
-```
-
-Event envelope:
-
-```json
-{
- "type": "vibe-coding.event",
- "protocolVersion": 1,
- "managedStreamId": 123,
- "slotSlug": "camp-code",
- "sessionKey": "openvibe-20260421231000-ab12cd34",
- "timestamp": "2026-04-21T23:10:01.100Z",
- "event": {
- "version": 1,
- "eventId": "28c49b5c-9070-4fd3-b750-b67d84338f64",
- "sessionKey": "openvibe-20260421231000-ab12cd34",
- "sessionId": "7b3e6b3a-78df-40d7-a53d-9acfa1b2cb30",
- "sequence": 17,
- "eventType": "response",
- "visibility": "public",
- "depth": "standard",
- "occurredAt": "2026-04-21T23:10:01.099Z",
- "summary": "Refactored the publish server to sanitize Copilot events before viewer fanout.",
- "source": {
- "channel": "chat-session",
- "rawType": "request.response"
- },
- "publisher": {
- "integrationId": "github-copilot",
- "integrationLabel": "GitHub Copilot",
- "vendor": "GitHub",
- "clientType": "vscode-extension",
- "clientName": "OpenVibe.Live Copilot Companion"
- },
- "response": {
- "text": "Refactored the publish server to sanitize Copilot events before viewer fanout.",
- "truncated": false
- },
- "metadata": {
- "requestId": "req-123",
- "responseId": "resp-456",
- "workspaceName": "openvibe",
- "model": "gpt-5.4"
- }
- }
-}
-```
-
-Ping:
-
-```json
-{
- "type": "vibe-coding.ping",
- "protocolVersion": 1,
- "timestamp": "2026-04-21T23:10:30.000Z"
-}
-```
-
-### Canonical event types
-
-- `session.status`
-- `prompt`
-- `response`
-- `thinking`
-- `tool.call`
-- `file.change`
-- `file.save`
-
-The extension publishes only canonical sanitized events. Raw debug log lines, raw transcript lines, raw chat-session lines, secrets, and absolute file paths are not broadcast directly.
-
-`source.channel` is publisher-defined so non-Copilot integrations can describe where an event came from without changing the canonical event types.
-
-## REST Endpoints
-
-Public feed for viewer widgets:
-
-```text
-GET /api/vibe-coding/channel/:username/:slotIdOrSlug/events?limit=18
-```
-
-Response:
-
-```json
-{
- "managed_stream": {
- "id": 123,
- "slug": "camp-code",
- "title": "Night Bus Refactor"
- },
- "live_stream_id": 456,
- "settings": {
- "enabled": 1,
- "viewer_depth": "standard"
- },
- "publisher": {
- "integrationId": "github-copilot",
- "integrationLabel": "GitHub Copilot",
- "vendor": "GitHub",
- "clientType": "vscode-extension",
- "clientName": "OpenVibe.Live Copilot Companion",
- "clientVersion": "0.0.1",
- "workspaceName": "openvibe",
- "machineName": "streamer-laptop",
- "viewerDepth": "standard",
- "capabilities": {
- "thinking": false,
- "toolCalls": true,
- "workspaceFileEvents": true
- }
- },
- "events": []
-}
-```
-
-Authenticated recent event history for the slot owner:
-
-```text
-GET /api/vibe-coding/managed/:managedStreamId/events?limit=50
-```
-
-Authenticated slot settings endpoints:
-
-```text
-GET /api/vibe-coding/managed/:managedStreamId/settings
-PUT /api/vibe-coding/managed/:managedStreamId/settings
-```
-
-The workspace UI currently saves the same settings through the existing managed-stream broadcast settings save flow, which persists to `managed_streams.broadcast_settings`.
-
-## Viewer Delivery
-
-Viewer clients do not connect to a second public WebSocket. Live slot events are projected through the existing stream chat socket as a new message type:
-
-```json
-{
- "type": "vibe-coding",
- "managed_stream_id": 123,
- "slot_slug": "camp-code",
- "delay_ms": 0,
- "event": {
- "eventId": "28c49b5c-9070-4fd3-b750-b67d84338f64",
- "eventType": "file.change",
- "summary": "Editing server/vibe-coding/publish-server.js",
- "file": {
- "name": "publish-server.js",
- "relativePath": "server/vibe-coding/publish-server.js",
- "operation": "edit",
- "changeCount": 3,
- "snippet": null
- }
- }
-}
-```
-
-The browser chat client inserts the vibe-coding widget above the slot chat header, hydrates its initial state from the REST endpoint, and can display the active publisher label so the same widget works for Copilot, Claude Code, or custom integrations.
\ No newline at end of file
\ No newline at end of file
+The full reference is Chat's README, section "Feeds: vibe coding, live".
@@ -428,7 +428,7 @@
@@ -428,7 +428,7 @@
/* ── Chat rendering (public/js/chat.js) ─────────────────────────────────────────────────────── */
/* ── Chat rendering (public/js/chat.js) ─────────────────────────────────────────────────────── */
/* Scroll containment without a wheel handler: a chat pane never drags the page along at its ends. */
/* Scroll containment without a wheel handler: a chat pane never drags the page along at its ends. */
-.chat-sidebar, .global-chat-main, .offline-global-chat, .chat-vibe-widget-feed, .chat-messages,
+.chat-sidebar, .global-chat-main, .offline-global-chat, .chat-messages,
.global-chat-messages, .fullscreen-chat-messages, .chat-users-panel, .rewards-panel, .fcw-messages, #pc-msgs {
.global-chat-messages, .fullscreen-chat-messages, .chat-users-panel, .rewards-panel, .fcw-messages, #pc-msgs {
overscroll-behavior: contain;
overscroll-behavior: contain;
}
}
@@ -103,758 +103,16 @@ body::after {
@@ -103,758 +103,16 @@ body::after {
border-radius: 50%;
border-radius: 50%;
}
}
-/* ── Vibe coding widget ───────────────────────────────────── */
-.chat-vibe-widget {
- display: flex;
- flex-direction: column;
- flex: 0 1 auto;
- min-height: 0;
- margin: 10px 0 12px;
- border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
- border-radius: 12px;
- background: linear-gradient(180deg, rgba(24, 25, 31, 0.96), rgba(15, 16, 22, 0.96));
- box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);
- overflow: hidden;
-}
-
-.chat-vibe-widget-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px;
- padding: 10px 12px;
- background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 12%, transparent), rgba(52, 152, 219, 0.05));
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.chat-vibe-widget-actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- margin-left: auto;
- gap: 10px;
-}
-
-.chat-vibe-widget-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 0.92rem;
- font-weight: 700;
- letter-spacing: 0.01em;
-}
-
-.chat-vibe-widget-title i {
- color: var(--accent-light);
-}
-
-.chat-vibe-widget-status {
- font-size: 0.74rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-secondary);
- transition: opacity 180ms ease, transform 180ms ease;
-}
-
-.chat-vibe-widget-status.is-hidden {
- opacity: 0;
- transform: scale(0.92);
- pointer-events: none;
- width: 0;
- min-width: 0;
-}
-
-.chat-vibe-widget-status.is-badge {
- padding: 3px 8px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--accent) 16%, transparent);
- color: var(--accent-light);
- letter-spacing: 0.02em;
- font-size: 0.8rem;
- text-transform: none;
-}
-
-.chat-vibe-widget-toggle {
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.04);
- color: var(--text-primary);
- font-size: 0.72rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- padding: 6px 12px;
- cursor: pointer;
- transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 180ms ease, box-shadow 180ms ease;
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-head {
- padding: 8px 10px;
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-actions {
- gap: 8px;
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-toggle {
- padding: 5px 10px;
- font-size: 0.7rem;
-}
-
-.chat-vibe-widget-toggle:hover,
-.chat-vibe-widget-toggle:focus-visible {
- background: rgba(255, 255, 255, 0.12);
- border-color: color-mix(in srgb, var(--accent) 34%, transparent);
- color: #fff;
- outline: none;
- transform: scale(1.02);
- box-shadow: 0 6px 14px rgba(0, 0, 0, 0.16);
-}
-
-.chat-vibe-widget-toggle:focus-visible {
- outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
- outline-offset: 2px;
-}
-
-.chat-vibe-widget-head {
- transition: background 220ms ease, border-color 220ms ease;
-}
-
-.chat-vibe-widget {
- transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background 180ms ease;
-}
-
-.chat-vibe-widget-feed {
- display: flex;
- flex-direction: column;
- flex: 0 1 auto;
- gap: 8px;
- min-height: 0;
- padding: 10px 12px 12px;
- max-height: min(34vh, 300px);
- overflow-y: auto;
- overscroll-behavior: contain;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: thin;
- touch-action: pan-y;
- transition: max-height 220ms ease, opacity 180ms ease, padding 180ms ease, margin 180ms ease;
- will-change: max-height, opacity;
-}
-
-.chat-vibe-new-msgs-indicator {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- margin: 0 12px 10px;
- padding: 7px 12px;
- border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--accent) 12%, transparent);
- color: var(--accent-light);
- font-size: 0.75rem;
- font-weight: 700;
- letter-spacing: 0.03em;
- box-shadow: 0 10px 20px rgba(0, 0, 0, 0.18);
- opacity: 0;
- max-height: 0;
- overflow: hidden;
- pointer-events: none;
- transition: opacity 180ms ease, max-height 220ms ease, margin 220ms ease, padding 220ms ease, transform 180ms ease;
- transform: translateY(6px);
-}
-
-.chat-vibe-new-msgs-indicator.is-visible {
- opacity: 1;
- max-height: 88px;
- margin: 8px 12px 10px;
- padding: 7px 12px;
- pointer-events: auto;
- transform: translateY(0);
-}
-
-.chat-vibe-new-msgs-indicator.is-hidden {
- opacity: 0;
- max-height: 0;
- margin: 0 12px 0;
- padding-top: 0;
- padding-bottom: 0;
- pointer-events: none;
- transform: translateY(6px);
-}
-
-.chat-vibe-new-msgs-indicator:hover,
-.chat-vibe-new-msgs-indicator:focus-visible {
- background: color-mix(in srgb, var(--accent) 18%, transparent);
- border-color: color-mix(in srgb, var(--accent) 38%, transparent);
- color: #fff;
- outline: none;
-}
-
-.chat-vibe-widget-feed::-webkit-scrollbar {
- width: 8px;
-}
-
-.chat-vibe-widget-feed::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.14);
- border-radius: 999px;
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-feed {
- max-height: 0;
- opacity: 0;
- padding-top: 0;
- padding-bottom: 0;
- margin: 0 12px 0;
- pointer-events: none;
- overflow: hidden;
-}
-
-.chat-vibe-widget.is-collapsed {
- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-head {
- border-bottom-color: rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
-}
-
-.chat-vibe-widget.is-collapsed .chat-vibe-widget-toggle {
- background: rgba(255, 255, 255, 0.06);
-}
-
-.chat-vibe-widget-empty {
- font-size: 0.82rem;
- color: var(--text-secondary);
-}
-
-.chat-vibe-item {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 10px 11px;
- border-radius: 10px;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
- border: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.chat-vibe-item-top {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: 6px;
-}
-
-.chat-vibe-item-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
-}
-
-.chat-vibe-item-label {
- font-size: 0.69rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--accent-light);
-}
-
-.chat-vibe-item-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.chat-vibe-item-meta-chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 7px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.05);
- font-size: 0.7rem;
- color: var(--text-secondary);
-}
-
-.chat-vibe-item-time {
- font-size: 0.71rem;
- color: var(--text-muted);
- white-space: nowrap;
-}
-
-.chat-vibe-item-text {
- font-size: 0.84rem;
- color: var(--text-primary);
- line-height: 1.45;
- word-break: break-word;
- white-space: pre-wrap;
- padding: 10px 12px;
- border-radius: 11px;
- background: rgba(0, 0, 0, 0.16);
- border-left: 3px solid rgba(255, 255, 255, 0.08);
- transition: background 180ms ease, border-color 180ms ease, transform 180ms ease;
-}
-
-.chat-vibe-item-text:hover {
- background: rgba(255, 255, 255, 0.05);
- border-left-color: rgba(255, 255, 255, 0.15);
- transform: translateY(-1px);
-}
-
-.chat-vibe-item-details {
- display: grid;
- gap: 6px;
-}
-
-.chat-vibe-item-detail {
- display: flex;
- align-items: flex-start;
- gap: 8px;
-}
-
-.chat-vibe-item-detail-label {
- min-width: 72px;
- padding-top: 1px;
- font-size: 0.66rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-muted);
-}
-
-.chat-vibe-item-detail-value {
- flex: 1;
- min-width: 0;
- font-size: 0.77rem;
- line-height: 1.45;
- color: var(--text-secondary);
- word-break: break-word;
- white-space: pre-wrap;
-}
-
-.chat-vibe-item-detail-value.is-code {
- padding: 7px 8px;
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.16);
- border: 1px solid rgba(255, 255, 255, 0.05);
- font-family: var(--font-mono);
- font-size: 0.73rem;
- color: var(--text-primary);
-}
-
-.chat-vibe-item-prompt .chat-vibe-item-label {
- color: #8fd0ff;
-}
-
-.chat-vibe-item-response .chat-vibe-item-label {
- color: #9fe3b5;
-}
-
-.chat-vibe-item-response .chat-vibe-item-text {
- border-left-color: rgba(159, 227, 181, 0.4);
-}
-
-.chat-vibe-item-thinking .chat-vibe-item-label {
- color: #f0c27a;
-}
-
-.chat-vibe-item-thinking .chat-vibe-item-text {
- border-left-color: rgba(240, 194, 122, 0.45);
-}
-
-.chat-vibe-item-tool-call .chat-vibe-item-label,
-.chat-vibe-item-tool .chat-vibe-item-label {
- color: #d4a5ff;
-}
-
-.chat-vibe-item-tool-call .chat-vibe-item-text,
-.chat-vibe-item-tool .chat-vibe-item-text {
- border-left-color: rgba(212, 165, 255, 0.42);
-}
-
-.chat-vibe-item-file-change .chat-vibe-item-label,
-.chat-vibe-item-file-save .chat-vibe-item-label {
- color: #ffb37a;
-}
-
-.chat-vibe-item-file-change .chat-vibe-item-text,
-.chat-vibe-item-file-save .chat-vibe-item-text {
- border-left-color: rgba(255, 179, 122, 0.45);
-}
-
-.chat-vibe-item-prompt .chat-vibe-item-text {
- border-left-color: rgba(143, 208, 255, 0.42);
-}
-
-.chat-vibe-system {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 0 2px;
- font-size: 0.73rem;
- color: var(--text-muted);
-}
-
-.chat-vibe-system-time {
- flex-shrink: 0;
- color: var(--text-muted);
-}
-
-.chat-vibe-system-text {
- color: var(--text-secondary);
-}
-
-.chat-vibe-turn {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.chat-vibe-turn.is-compact {
- gap: 4px;
-}
-
-.chat-vibe-turn-prompt-row {
- display: flex;
- justify-content: flex-end;
-}
-
-.chat-vibe-turn-prompt {
- max-width: min(88%, 520px);
- padding: 7px 11px;
- border-radius: 14px 14px 4px 14px;
- background: rgba(143, 208, 255, 0.12);
- border: 1px solid rgba(143, 208, 255, 0.2);
- color: var(--text-primary);
- font-size: 0.81rem;
- line-height: 1.4;
- word-break: break-word;
-}
-
-.chat-vibe-turn-response {
- position: relative;
- padding: 10px 11px;
- border-radius: 12px;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.02));
- border: 1px solid rgba(255, 255, 255, 0.07);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
-}
-
-.chat-vibe-turn.is-compact .chat-vibe-turn-response {
- padding: 8px 10px;
- border-radius: 10px;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015));
-}
-
-.chat-vibe-turn.is-update .chat-vibe-turn-response {
- border-color: rgba(255, 255, 255, 0.05);
-}
-
-.chat-vibe-turn-response-top {
- position: absolute;
- top: 10px;
- right: 11px;
- display: flex;
- align-items: center;
- justify-content: flex-end;
- pointer-events: none;
-}
-
-.chat-vibe-turn.is-compact .chat-vibe-turn-response-top {
- top: 8px;
- right: 10px;
-}
-
-.chat-vibe-turn-time {
- font-size: 0.68rem;
- color: var(--text-muted);
- padding: 2px 6px;
- border-radius: 999px;
- background: rgba(0, 0, 0, 0.18);
- border: 1px solid rgba(255, 255, 255, 0.05);
-}
-
-.chat-vibe-turn-body {
- display: flex;
- flex-direction: column;
- gap: 7px;
- padding-right: 68px;
-}
-
-.chat-vibe-turn.is-compact .chat-vibe-turn-body {
- gap: 5px;
- padding-right: 62px;
-}
-
-.chat-vibe-turn-paragraph {
- margin: 0;
- font-size: 0.84rem;
- line-height: 1.46;
- color: var(--text-primary);
- word-break: break-word;
- white-space: pre-wrap;
-}
-
-.chat-vibe-turn.is-compact .chat-vibe-turn-paragraph {
- font-size: 0.79rem;
- line-height: 1.42;
-}
-
-.chat-vibe-turn.is-update .chat-vibe-turn-paragraph {
- color: var(--text-secondary);
-}
-
-.chat-vibe-turn-paragraph.is-muted {
- font-size: 0.78rem;
- color: var(--text-secondary);
-}
-
-.chat-vibe-turn-bullets {
- margin: 2px 0 0;
- padding-left: 18px;
- display: grid;
- gap: 5px;
-}
-
-.chat-vibe-turn-list-item {
- font-size: 0.8rem;
- line-height: 1.45;
- color: var(--text-primary);
-}
-
-.chat-vibe-turn-list-item.is-muted {
- font-size: 0.77rem;
- color: var(--text-secondary);
-}
-
-.chat-vibe-turn-disclosure {
- margin-top: 7px;
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.03);
- overflow: hidden;
-}
-
-.chat-vibe-turn-disclosure summary {
- display: grid;
- grid-template-columns: 18px auto minmax(0, 1fr) auto;
- align-items: center;
- column-gap: 8px;
- row-gap: 2px;
- padding: 8px 10px;
- cursor: pointer;
- list-style: none;
- font-size: 0.75rem;
- font-weight: 600;
- color: var(--text-secondary);
- position: relative;
-}
-
-.chat-vibe-turn-disclosure-kicker {
- grid-column: 2 / 3;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.chat-vibe-turn-disclosure-title {
- grid-column: 3 / 4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.chat-vibe-turn-disclosure-meta {
- grid-column: 4 / 5;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.chat-vibe-turn-disclosure summary::-webkit-details-marker {
- display: none;
-}
-
-.chat-vibe-turn-disclosure summary::before {
- content: '›';
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- margin-right: 4px;
- font-size: 0.95rem;
- line-height: 1;
- color: var(--text-muted);
- transition: transform 0.16s ease;
- transform-origin: center;
-}
-
-.chat-vibe-turn-disclosure[open] summary::before {
- transform: rotate(90deg);
-}
-
-.chat-vibe-turn-disclosure summary:hover::before,
-.chat-vibe-turn-disclosure summary:focus-visible::before {
- color: var(--text-primary);
-}
-
-.chat-vibe-turn-disclosure-body {
- display: grid;
- gap: 8px;
- padding: 0 10px 10px;
-}
-
-.chat-vibe-turn-disclosure-kicker {
- display: inline-flex;
- align-items: center;
- padding: 2px 7px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.06);
- font-size: 0.64rem;
- font-weight: 700;
- letter-spacing: 0.07em;
- text-transform: uppercase;
- color: var(--text-secondary);
-}
-
-.chat-vibe-turn-disclosure-title {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--text-primary);
-}
-
-.chat-vibe-turn-disclosure-meta {
- font-size: 0.67rem;
- color: var(--text-muted);
- white-space: nowrap;
-}
-
-.chat-vibe-turn-disclosure-progress .chat-vibe-turn-disclosure-kicker {
- color: #8fd0ff;
-}
-
-.chat-vibe-turn-disclosure-thinking .chat-vibe-turn-disclosure-kicker {
- color: #f0c27a;
-}
-
-.chat-vibe-turn-disclosure-tools .chat-vibe-turn-disclosure-kicker {
- color: #d4a5ff;
-}
-
-.chat-vibe-turn-disclosure-commands .chat-vibe-turn-disclosure-kicker {
- color: #8fd0ff;
-}
-
-.chat-vibe-turn-disclosure-files .chat-vibe-turn-disclosure-kicker {
- color: #9fe3b5;
-}
-
-.chat-vibe-turn-note {
- margin: 0;
- padding: 7px 8px;
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.16);
- border: 1px solid rgba(255, 255, 255, 0.05);
- font-size: 0.77rem;
- line-height: 1.45;
- color: var(--text-secondary);
- white-space: pre-wrap;
-}
-
-.chat-vibe-turn-note-mono {
- font-family: var(--font-mono);
- font-size: 0.72rem;
- color: var(--text-primary);
-}
-
-.chat-vibe-turn-note-mono.is-command {
- background: rgba(143, 208, 255, 0.08);
- border-color: rgba(143, 208, 255, 0.16);
-}
-
-.chat-vibe-turn-list-row {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 10px;
-}
-
-.chat-vibe-turn-list-main {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-.chat-vibe-turn-list-title {
- font-size: 0.78rem;
- font-weight: 700;
- color: var(--text-primary);
- word-break: break-word;
-}
-
-.chat-vibe-turn-list-title.is-mono {
- font-family: var(--font-mono);
- font-size: 0.73rem;
-}
-
-.chat-vibe-turn-list-subtitle {
- font-size: 0.71rem;
- line-height: 1.4;
- color: var(--text-secondary);
- word-break: break-word;
-}
-
-.chat-vibe-turn-pill-group {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.chat-vibe-turn-pill {
- display: inline-flex;
- align-items: center;
- padding: 3px 7px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.09);
- background: rgba(255, 255, 255, 0.04);
- color: var(--text-secondary);
- font-size: 0.68rem;
- font-weight: 700;
- line-height: 1.2;
-}
-
-.chat-vibe-turn-pill.is-count {
- color: #d9c6ff;
- background: rgba(212, 165, 255, 0.12);
- border-color: rgba(212, 165, 255, 0.18);
-}
-
-.chat-vibe-turn-pill.is-edit {
- color: #9fe3b5;
- background: rgba(77, 184, 129, 0.14);
- border-color: rgba(77, 184, 129, 0.2);
-}
-
-.chat-vibe-turn-pill.is-save {
- color: #8fd0ff;
- background: rgba(143, 208, 255, 0.12);
- border-color: rgba(143, 208, 255, 0.18);
+/* ── The feed card (public/js/chat.js refreshFeedCard): OpenVibe.Chat's embed in a frame ── */
+.chat-feed-card {
+ display: block;
+ width: 100%;
+ height: 42px;
+ margin: 8px 0 10px;
+ border: 0;
+ background: transparent;
}
}
-.chat-vibe-turn-file-card {
- display: grid;
- gap: 7px;
-}
/* Both glows stay inside the page's width — an absolutely positioned box past the right edge
/* Both glows stay inside the page's width — an absolutely positioned box past the right edge
widens the document and scrolls it sideways. The gradient centre sits on the corner instead. */
widens the document and scrolls it sideways. The gradient centre sits on the corner instead. */
body::before {
body::before {
@@ -4868,11 +4126,8 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
@@ -4868,11 +4126,8 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
border-radius: 16px 16px 0 0;
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 32px rgba(0,0,0,0.45);
box-shadow: 0 -8px 32px rgba(0,0,0,0.45);
}
}
- .chat-vibe-widget {
- margin: 8px 0 10px;
- }
- .chat-vibe-widget-feed {
- max-height: min(24dvh, 200px);
+ .chat-feed-card {
+ margin: 6px 0 8px;
}
}
.chat-sidebar.mobile-chat-open {
.chat-sidebar.mobile-chat-open {
transform: translateY(0);
transform: translateY(0);
@@ -8462,9 +7717,6 @@ body.theme-light .btn-outline:hover {
@@ -8462,9 +7717,6 @@ body.theme-light .btn-outline:hover {
}
}
body.mobile-chat-visible .mobile-chat-toggle { display: none !important; }
body.mobile-chat-visible .mobile-chat-toggle { display: none !important; }
body.mobile-chat-visible #messenger-toggle { display: none !important; }
body.mobile-chat-visible #messenger-toggle { display: none !important; }
- .chat-vibe-widget-feed {
- max-height: 240px;
- }
.video-container { max-height: 50vh; }
.video-container { max-height: 50vh; }
.mobile-chat-toggle { display: flex; }
.mobile-chat-toggle { display: flex; }
/* The chat is an overlay at ≤1180px — give its header a working close button. */
/* The chat is an overlay at ≤1180px — give its header a working close button. */
@@ -37,98 +37,6 @@ let _wsState = {
@@ -37,98 +37,6 @@ let _wsState = {
_liveStatsTimer: null, // interval for updating live stats
_liveStatsTimer: null, // interval for updating live stats
};
};
-const WS_VIBE_DEFAULTS = {
- enabled: 0,
- widget_title: 'Vibe Coding',
- viewer_depth: 'standard',
- show_prompts: 1,
- show_responses: 1,
- show_thinking: 0,
- show_tool_calls: 1,
- show_tool_arguments: 0,
- show_file_events: 1,
- show_file_snippets: 0,
- redact_file_paths: 1,
- paused: 0,
- delay_ms: 0,
- max_events: 18,
- max_prompt_chars: 220,
- max_response_chars: 360,
- max_thinking_chars: 140,
- max_tool_chars: 140,
- max_snippet_chars: 140,
-};
-
-function _wsNormalizeVibeSettings(settings) {
- const source = settings && typeof settings === 'object' ? settings : {};
- const boolField = (key, fallback) => {
- if (source[key] === undefined || source[key] === null) return fallback;
- return source[key] ? 1 : 0;
- };
- const intField = (key, fallback, min, max) => {
- const value = parseInt(source[key], 10);
- if (!Number.isFinite(value)) return fallback;
- return Math.min(max, Math.max(min, value));
- };
- const depth = ['headline', 'standard', 'deep'].includes(source.viewer_depth) ? source.viewer_depth : WS_VIBE_DEFAULTS.viewer_depth;
- return {
- enabled: boolField('enabled', WS_VIBE_DEFAULTS.enabled),
- widget_title: String(source.widget_title || WS_VIBE_DEFAULTS.widget_title).trim() || WS_VIBE_DEFAULTS.widget_title,
- viewer_depth: depth,
- show_prompts: boolField('show_prompts', WS_VIBE_DEFAULTS.show_prompts),
- show_responses: boolField('show_responses', WS_VIBE_DEFAULTS.show_responses),
- show_thinking: boolField('show_thinking', WS_VIBE_DEFAULTS.show_thinking),
- show_tool_calls: boolField('show_tool_calls', WS_VIBE_DEFAULTS.show_tool_calls),
- show_tool_arguments: boolField('show_tool_arguments', WS_VIBE_DEFAULTS.show_tool_arguments),
- show_file_events: boolField('show_file_events', WS_VIBE_DEFAULTS.show_file_events),
- show_file_snippets: boolField('show_file_snippets', WS_VIBE_DEFAULTS.show_file_snippets),
- redact_file_paths: (source.redact_file_paths === 0 || source.redact_file_paths === false) ? 0 : 1,
- paused: boolField('paused', WS_VIBE_DEFAULTS.paused),
- delay_ms: intField('delay_ms', WS_VIBE_DEFAULTS.delay_ms, 0, 120000),
- max_events: intField('max_events', WS_VIBE_DEFAULTS.max_events, 1, 100),
- max_prompt_chars: intField('max_prompt_chars', WS_VIBE_DEFAULTS.max_prompt_chars, 60, 1200),
- max_response_chars: intField('max_response_chars', WS_VIBE_DEFAULTS.max_response_chars, 80, 2400),
- max_thinking_chars: intField('max_thinking_chars', WS_VIBE_DEFAULTS.max_thinking_chars, 40, 1200),
- max_tool_chars: intField('max_tool_chars', WS_VIBE_DEFAULTS.max_tool_chars, 40, 1200),
- max_snippet_chars: intField('max_snippet_chars', WS_VIBE_DEFAULTS.max_snippet_chars, 40, 1200),
- };
-}
-
-function _wsReadStoredVibeSettings(profile) {
- if (!profile || typeof profile !== 'object') return null;
- return profile.vibeCoding || profile.vibe_coding || null;
-}
-
-function _wsGetVibeSettings() {
- _wsState.profile = _wsState.profile || {};
- _wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));
- return _wsState.profile.vibeCoding;
-}
-
-function _wsCollectVibeSettingsFromDom() {
- return _wsNormalizeVibeSettings({
- enabled: document.getElementById('bc-ws-vibe-enabled')?.checked ? 1 : 0,
- widget_title: document.getElementById('bc-ws-vibe-title')?.value.trim() || WS_VIBE_DEFAULTS.widget_title,
- viewer_depth: document.getElementById('bc-ws-vibe-depth')?.value || WS_VIBE_DEFAULTS.viewer_depth,
- show_prompts: document.getElementById('bc-ws-vibe-prompts')?.checked ? 1 : 0,
- show_responses: document.getElementById('bc-ws-vibe-responses')?.checked ? 1 : 0,
- show_thinking: document.getElementById('bc-ws-vibe-thinking')?.checked ? 1 : 0,
- show_tool_calls: document.getElementById('bc-ws-vibe-tools')?.checked ? 1 : 0,
- show_tool_arguments: document.getElementById('bc-ws-vibe-tool-args')?.checked ? 1 : 0,
- show_file_events: document.getElementById('bc-ws-vibe-files')?.checked ? 1 : 0,
- show_file_snippets: document.getElementById('bc-ws-vibe-snippets')?.checked ? 1 : 0,
- redact_file_paths: document.getElementById('bc-ws-vibe-redact-paths')?.checked ? 1 : 0,
- paused: document.getElementById('bc-ws-vibe-paused')?.checked ? 1 : 0,
- delay_ms: document.getElementById('bc-ws-vibe-delay')?.value || WS_VIBE_DEFAULTS.delay_ms,
- max_events: document.getElementById('bc-ws-vibe-max-events')?.value || WS_VIBE_DEFAULTS.max_events,
- max_prompt_chars: document.getElementById('bc-ws-vibe-max-prompt')?.value || WS_VIBE_DEFAULTS.max_prompt_chars,
- max_response_chars: document.getElementById('bc-ws-vibe-max-response')?.value || WS_VIBE_DEFAULTS.max_response_chars,
- max_thinking_chars: document.getElementById('bc-ws-vibe-max-thinking')?.value || WS_VIBE_DEFAULTS.max_thinking_chars,
- max_tool_chars: document.getElementById('bc-ws-vibe-max-tool')?.value || WS_VIBE_DEFAULTS.max_tool_chars,
- max_snippet_chars: document.getElementById('bc-ws-vibe-max-snippet')?.value || WS_VIBE_DEFAULTS.max_snippet_chars,
- });
-}
-
/* ── Init ────────────────────────────────────────────────────── */
/* ── Init ────────────────────────────────────────────────────── */
// PowerChat relay controls only make sense for streamers with a connected PowerChat
// PowerChat relay controls only make sense for streamers with a connected PowerChat
@@ -264,7 +172,6 @@ async function _wsLoadProfile(managedStreamId) {
@@ -264,7 +172,6 @@ async function _wsLoadProfile(managedStreamId) {
// OpenRestream-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).
// OpenRestream-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).
_wsState.streamKeyHint = data.stream_key_hint || null;
_wsState.streamKeyHint = data.stream_key_hint || null;
_wsState.profile = data.broadcast_settings || {};
_wsState.profile = data.broadcast_settings || {};
- _wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));
_wsState.whipUrlBase = data.whip_url_base || null;
_wsState.whipUrlBase = data.whip_url_base || null;
_wsState.whipUrlSource = data.whip_url_source || null;
_wsState.whipUrlSource = data.whip_url_source || null;
_wsState.whipUrlWarning = data.whip_url_warning || null;
_wsState.whipUrlWarning = data.whip_url_warning || null;
@@ -304,13 +211,9 @@ function _wsRenderPanel() {
@@ -304,13 +211,9 @@ function _wsRenderPanel() {
panel.style.display = '';
panel.style.display = '';
const p = _wsState.profile;
const p = _wsState.profile;
- const vibe = _wsGetVibeSettings();
const isLive = _wsIsManagedStreamLive(ms.id);
const isLive = _wsIsManagedStreamLive(ms.id);
const method = ms.streaming_method || 'browser';
const method = ms.streaming_method || 'browser';
const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';
const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';
- const vibePublisherUrl = _wsGetVibePublisherUrl();
- const vibeFeedUrl = _wsGetVibeFeedUrl(ms);
- const vibeSlotRef = ms.slug || ms.id;
// Class C: find active session
// Class C: find active session
let liveSessionId = null;
let liveSessionId = null;
@@ -822,143 +725,9 @@ function _wsRenderPanel() {
@@ -822,143 +725,9 @@ function _wsRenderPanel() {
<summary><i class="fa-solid fa-code"></i> Vibe Coding Feed</summary>
<summary><i class="fa-solid fa-code"></i> Vibe Coding Feed</summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
- Show a live coding activity widget above this slot's chat room. Any compatible publisher can send a sanitized coding feed into this slot, and viewers only see the fields enabled here.
- </p>
- <div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">
- <div style="font-weight:600;margin-bottom:8px"><i class="fa-solid fa-plug-circle-bolt"></i> VS Code Quick Setup</div>
- <ol style="margin:0 0 10px 18px;font-size:0.82rem;color:var(--text-secondary);display:flex;flex-direction:column;gap:6px">
- <li>Create an API token on the Dashboard using the <strong>GitHub Copilot Companion</strong> preset. That preset includes the <code>vibe_coding_publish</code> scope.</li>
- <li>In VS Code, run <strong>OpenVibe.Live Copilot Companion: Open Settings</strong>.</li>
- <li>Paste the publisher URL below into <code>copilotChatWebSocket.url</code>.</li>
- <li>Paste this slot ID into <code>copilotChatWebSocket.managedStreamId</code> and optionally the slot slug into <code>copilotChatWebSocket.slotSlug</code>.</li>
- <li>Paste the API token into <code>copilotChatWebSocket.apiToken</code>, then run <strong>Connect</strong>.</li>
- </ol>
- <div style="display:flex;flex-wrap:wrap;gap:8px">
- <button class="btn btn-small btn-outline" onclick="_wsCopyVibeSettingsTemplate()"><i class="fa-solid fa-copy"></i> Copy Settings Template</button>
- <button class="btn btn-small btn-outline" onclick="navigate('/dashboard')"><i class="fa-solid fa-key"></i> Open Dashboard Tokens</button>
- </div>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:2">
- <label>Publisher WebSocket URL</label>
- <div style="display:flex;gap:6px;align-items:center">
- <input type="text" id="bc-ws-vibe-publisher-url" class="form-input form-input-sm" readonly value="${esc(vibePublisherUrl)}">
- <button class="btn btn-small" onclick="_wsCopyVibePublisherUrl()" title="Copy URL"><i class="fa-solid fa-copy"></i></button>
- </div>
- </div>
- <div class="form-group" style="flex:1">
- <label>Managed Stream ID</label>
- <div style="display:flex;gap:6px;align-items:center">
- <input type="text" id="bc-ws-vibe-managed-stream-id" class="form-input form-input-sm" readonly value="${ms.id}">
- <button class="btn btn-small" onclick="_wsCopyVibeManagedStreamId()" title="Copy ID"><i class="fa-solid fa-copy"></i></button>
- </div>
- </div>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:1">
- <label>Slot Slug</label>
- <div style="display:flex;gap:6px;align-items:center">
- <input type="text" id="bc-ws-vibe-slot-slug" class="form-input form-input-sm" readonly value="${esc(ms.slug || '')}" placeholder="Optional">
- <button class="btn btn-small" onclick="_wsCopyVibeSlotSlug()" title="Copy slug"><i class="fa-solid fa-copy"></i></button>
- </div>
- </div>
- <div class="form-group" style="flex:2">
- <label>Public Feed Endpoint</label>
- <div style="display:flex;gap:6px;align-items:center">
- <input type="text" id="bc-ws-vibe-feed-url" class="form-input form-input-sm" readonly value="${esc(vibeFeedUrl)}">
- <button class="btn btn-small" onclick="_wsCopyVibeFeedUrl()" title="Copy feed URL"><i class="fa-solid fa-copy"></i></button>
- </div>
- </div>
- </div>
- <div class="form-group">
- <label class="bc-toggle-label">
- <input type="checkbox" id="bc-ws-vibe-enabled" ${vibe.enabled ? 'checked' : ''} onchange="_wsSlotSettingChanged()">
- Enable vibe-coding widget for this slot
- </label>
- </div>
- <div class="form-group">
- <label class="bc-toggle-label">
- <input type="checkbox" id="bc-ws-vibe-paused" ${vibe.paused ? 'checked' : ''} onchange="_wsSlotSettingChanged()">
- Pause viewer updates without disconnecting the publisher
- </label>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:2">
- <label>Widget Title</label>
- <input type="text" id="bc-ws-vibe-title" class="form-input form-input-sm"
- value="${esc(vibe.widget_title || 'Vibe Coding')}" maxlength="80"
- oninput="_wsSlotSettingChanged()">
- </div>
- <div class="form-group" style="flex:1">
- <label>Viewer Depth</label>
- <select id="bc-ws-vibe-depth" class="form-input" onchange="_wsSlotSettingChanged()">
- <option value="headline" ${vibe.viewer_depth === 'headline' ? 'selected' : ''}>Headline</option>
- <option value="standard" ${vibe.viewer_depth === 'standard' ? 'selected' : ''}>Standard</option>
- <option value="deep" ${vibe.viewer_depth === 'deep' ? 'selected' : ''}>Deep</option>
- </select>
- </div>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:1">
- <label>Delay (ms)</label>
- <input type="number" id="bc-ws-vibe-delay" class="form-input form-input-sm"
- value="${vibe.delay_ms || 0}" min="0" max="120000"
- oninput="_wsSlotSettingChanged()">
- </div>
- <div class="form-group" style="flex:1">
- <label>Max Events</label>
- <input type="number" id="bc-ws-vibe-max-events" class="form-input form-input-sm"
- value="${vibe.max_events || 18}" min="1" max="100"
- oninput="_wsSlotSettingChanged()">
- </div>
- </div>
- <div class="bc-ws-row" style="flex-wrap:wrap">
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-prompts" ${vibe.show_prompts ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Prompts</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-responses" ${vibe.show_responses ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Responses</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-thinking" ${vibe.show_thinking ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Thinking summaries</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-tools" ${vibe.show_tool_calls ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Tool calls</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-tool-args" ${vibe.show_tool_arguments ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Tool argument previews</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-files" ${vibe.show_file_events ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> File events</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-snippets" ${vibe.show_file_snippets ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> File snippets</label>
- <label class="bc-toggle-label" style="min-width:220px"><input type="checkbox" id="bc-ws-vibe-redact-paths" ${vibe.redact_file_paths ? 'checked' : ''} onchange="_wsSlotSettingChanged()"> Redact file paths</label>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:1">
- <label>Prompt Chars</label>
- <input type="number" id="bc-ws-vibe-max-prompt" class="form-input form-input-sm"
- value="${vibe.max_prompt_chars || 220}" min="60" max="1200"
- oninput="_wsSlotSettingChanged()">
- </div>
- <div class="form-group" style="flex:1">
- <label>Response Chars</label>
- <input type="number" id="bc-ws-vibe-max-response" class="form-input form-input-sm"
- value="${vibe.max_response_chars || 360}" min="80" max="2400"
- oninput="_wsSlotSettingChanged()">
- </div>
- <div class="form-group" style="flex:1">
- <label>Thinking Chars</label>
- <input type="number" id="bc-ws-vibe-max-thinking" class="form-input form-input-sm"
- value="${vibe.max_thinking_chars || 140}" min="40" max="1200"
- oninput="_wsSlotSettingChanged()">
- </div>
- </div>
- <div class="bc-ws-row">
- <div class="form-group" style="flex:1">
- <label>Tool Chars</label>
- <input type="number" id="bc-ws-vibe-max-tool" class="form-input form-input-sm"
- value="${vibe.max_tool_chars || 140}" min="40" max="1200"
- oninput="_wsSlotSettingChanged()">
- </div>
- <div class="form-group" style="flex:1">
- <label>Snippet Chars</label>
- <input type="number" id="bc-ws-vibe-max-snippet" class="form-input form-input-sm"
- value="${vibe.max_snippet_chars || 140}" min="40" max="1200"
- oninput="_wsSlotSettingChanged()">
- </div>
- </div>
- <p class="muted" style="font-size:0.78rem;margin:8px 0 0">
- This slot listens on <code>${esc(vibeSlotRef)}</code>. The same publish/feed endpoints also work for other coding publishers, not just the Copilot reference extension.
+ Show what you are building while you stream: Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and viewers follow along in a card above this slot's chat. Feeds live on OpenVibe Chat: make one there, pick this slot, and paste its Claude Code hooks. Secrets are wiped before anyone sees a step.
</p>
</p>
+ <a class="btn btn-small btn-outline" href="https://openvibe.chat/feeds" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square"></i> Set up a feed on openvibe.chat</a>
</div>
</div>
</details>
</details>
@@ -1954,7 +1723,6 @@ async function _wsSaveAll() {
@@ -1954,7 +1723,6 @@ async function _wsSaveAll() {
screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',
screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',
screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',
screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',
screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,
screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,
- vibeCoding: _wsCollectVibeSettingsFromDom(),
};
};
// Save structured fields
// Save structured fields
@@ -2080,7 +1848,6 @@ function _wsSlotSettingChanged() {
@@ -2080,7 +1848,6 @@ function _wsSlotSettingChanged() {
ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
- _wsState.profile.vibeCoding = _wsCollectVibeSettingsFromDom();
_wsMarkDirty();
_wsMarkDirty();
}
}
@@ -3292,63 +3059,6 @@ function _wsCopyOverlayUrl() {
@@ -3292,63 +3059,6 @@ function _wsCopyOverlayUrl() {
.catch(() => { prompt('Copy manually:', input.value); });
.catch(() => { prompt('Copy manually:', input.value); });
}
}
-function _wsGetVibePublisherUrl() {
- const origin = String(window.location.origin || '').replace(/\/$/, '');
- if (!origin) return '/ws/vibe-coding/publish';
- return origin.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/vibe-coding/publish';
-}
-
-function _wsGetVibeFeedUrl(ms = _wsState.selectedMs) {
- if (!ms || !currentUser?.username) return '';
- return `${window.location.origin.replace(/\/$/, '')}/api/vibe-coding/channel/${encodeURIComponent(currentUser.username)}/${encodeURIComponent(String(ms.slug || ms.id))}/events`;
-}
-
-function _wsCopyVibePublisherUrl() {
- _wsCopyText(_wsGetVibePublisherUrl(), 'Vibe publisher URL copied');
-}
-
-function _wsCopyVibeManagedStreamId() {
- if (!_wsState.selectedMs?.id) {
- toast('No managed stream selected', 'error');
- return;
- }
- _wsCopyText(String(_wsState.selectedMs.id), 'Managed stream ID copied');
-}
-
-function _wsCopyVibeSlotSlug() {
- if (!_wsState.selectedMs?.slug) {
- toast('This slot does not have a slug yet', 'error');
- return;
- }
- _wsCopyText(String(_wsState.selectedMs.slug), 'Slot slug copied');
-}
-
-function _wsCopyVibeFeedUrl() {
- const url = _wsGetVibeFeedUrl();
- if (!url) {
- toast('Public feed URL unavailable', 'error');
- return;
- }
- _wsCopyText(url, 'Public feed URL copied');
-}
-
-function _wsCopyVibeSettingsTemplate() {
- if (!_wsState.selectedMs?.id) {
- toast('No managed stream selected', 'error');
- return;
- }
- const template = {
- copilotChatWebSocket: {
- url: _wsGetVibePublisherUrl(),
- apiToken: 'PASTE_VIBE_CODING_TOKEN_HERE',
- managedStreamId: _wsState.selectedMs.id,
- slotSlug: _wsState.selectedMs.slug || '',
- autoConnect: true,
- },
- };
- _wsCopyText(JSON.stringify(template, null, 2), 'Extension settings template copied');
-}
-
function _wsPreviewOverlay() {
function _wsPreviewOverlay() {
const input = document.getElementById('bc-ws-overlay-url');
const input = document.getElementById('bc-ws-overlay-url');
if (!input) return;
if (!input) return;
@@ -60,48 +60,6 @@ let _globalFeedReconnectTimer = null;
@@ -60,48 +60,6 @@ let _globalFeedReconnectTimer = null;
let _globalFeedReconnectDelay = 3000;
let _globalFeedReconnectDelay = 3000;
const _GLOBAL_FEED_RECONNECT_MAX = 20000;
const _GLOBAL_FEED_RECONNECT_MAX = 20000;
-// ── Vibe coding widget ──────────────────────────────────────
-function _loadVibeWidgetCollapsedPreference() {
- try {
- return localStorage.getItem('openvibe_vibe_widget_collapsed') === '1';
- } catch {
- return false;
- }
-}
-
-function _saveVibeWidgetCollapsedPreference(collapsed) {
- try {
- if (collapsed) {
- localStorage.setItem('openvibe_vibe_widget_collapsed', '1');
- return;
- }
- localStorage.removeItem('openvibe_vibe_widget_collapsed');
- } catch {
- // Ignore storage failures so the widget still works in restricted contexts.
- }
-}
-
-let _vibeWidgetState = {
- key: null,
- panel: null,
- feed: null,
- indicator: null,
- settings: null,
- publisher: null,
- events: [],
- timers: [],
- collapsed: _loadVibeWidgetCollapsedPreference(),
- userScrolledUp: false,
- unreadCount: 0,
- refreshTimer: null,
- syncState: 'idle',
- lastSyncedAt: 0,
- openDisclosures: new Set(),
-};
-const VIBE_WIDGET_FALLBACK_MAX = 18;
-const VIBE_WIDGET_REFRESH_MS = 5000;
-const VIBE_WIDGET_SCROLL_THRESHOLD = 56;
-
// ── Slow mode state ─────────────────────────────────────────
// ── Slow mode state ─────────────────────────────────────────
let chatSlowModeSeconds = 0;
let chatSlowModeSeconds = 0;
let chatSlowModeCooldownTimer = null;
let chatSlowModeCooldownTimer = null;
@@ -994,7 +952,14 @@ function getChatRenderTargetId() {
@@ -994,7 +952,14 @@ function getChatRenderTargetId() {
return getChatEl().messages?.id || null;
return getChatEl().messages?.id || null;
}
}
-function _getVibeWidgetContext() {
+// ── The feed card: what the streamer is building, live ─────
+// Vibe coding feeds live on OpenVibe.Chat (its server/feeds/, openvibe.chat/feeds): a streamer shows one on a channel
+// slot, and Chat answers which feed a slot shows. Its card is Chat's own embed, framed above the chat; it folds itself
+// and tells this page its height. Chat also sends the slot's chat the steps as `vibe-coding` frames (not needed here).
+const FEED_ORIGIN = 'https://openvibe.chat';
+const _feedCard = { key: null, frame: null, seq: 0 };
+
+function _feedCardContext() {
let streamData = currentStreamData;
let streamData = currentStreamData;
if ((!streamData || streamData.id !== chatStreamId) && typeof broadcastState !== 'undefined' && broadcastState?.streams && chatStreamId) {
if ((!streamData || streamData.id !== chatStreamId) && typeof broadcastState !== 'undefined' && broadcastState?.streams && chatStreamId) {
const stateEntry = broadcastState.streams instanceof Map
const stateEntry = broadcastState.streams instanceof Map
@@ -1004,1100 +969,53 @@ function _getVibeWidgetContext() {
@@ -1004,1100 +969,53 @@ function _getVibeWidgetContext() {
streamData = stateEntry.streamData;
streamData = stateEntry.streamData;
}
}
}
}
- const username = streamData?.username || currentUser?.username || null;
- const slotRef = streamData?.managed_stream_slug || streamData?.managed_stream_id || null;
const managedStreamId = streamData?.managed_stream_id || null;
const managedStreamId = streamData?.managed_stream_id || null;
- if (!username || !slotRef || !managedStreamId) return null;
- return { username, slotRef, managedStreamId };
-}
-
-function _clearVibeWidgetTimers() {
- (_vibeWidgetState.timers || []).forEach((timerId) => clearTimeout(timerId));
- _vibeWidgetState.timers = [];
-}
-
-function _clearVibeWidgetRefreshTimer() {
- if (_vibeWidgetState.refreshTimer) {
- clearInterval(_vibeWidgetState.refreshTimer);
- _vibeWidgetState.refreshTimer = null;
- }
-}
-
-function _setVibeWidgetCollapsed(collapsed) {
- _vibeWidgetState.collapsed = !!collapsed;
- _saveVibeWidgetCollapsedPreference(_vibeWidgetState.collapsed);
- if (_vibeWidgetState.panel) {
- _renderVibeWidget();
- }
-}
-
-function _toggleVibeWidgetCollapsed() {
- _setVibeWidgetCollapsed(!_vibeWidgetState.collapsed);
-}
-
-function _resetVibeWidget(removePanel = false) {
- _clearVibeWidgetTimers();
- _clearVibeWidgetRefreshTimer();
- _vibeWidgetState.key = null;
- _vibeWidgetState.settings = null;
- _vibeWidgetState.publisher = null;
- _vibeWidgetState.events = [];
- _vibeWidgetState.userScrolledUp = false;
- _vibeWidgetState.unreadCount = 0;
- _vibeWidgetState.syncState = 'idle';
- _vibeWidgetState.lastSyncedAt = 0;
- if (removePanel && _vibeWidgetState.panel) {
- _vibeWidgetState.panel.remove();
- _vibeWidgetState.panel = null;
- _vibeWidgetState.feed = null;
- _vibeWidgetState.indicator = null;
- _vibeWidgetState.openDisclosures.clear();
- return;
- }
- if (_vibeWidgetState.feed) {
- _vibeWidgetState.feed.innerHTML = '';
- }
- _hideVibeWidgetNewMessagesIndicator();
- if (_vibeWidgetState.panel) {
- _vibeWidgetState.panel.style.display = 'none';
- }
+ return managedStreamId ? { managedStreamId } : null;
}
}
-function _getVibeWidgetHost() {
- const { messages } = getChatEl();
- if (!messages) return null;
- return messages.closest('#chat-sidebar, .chat-sidebar, .bc-chat-sidebar, .chat-panel, .bc-ws-panel, .bc-chat-panel')
- || messages.parentElement
- || null;
-}
-
-function _ensureVibeWidgetPanel() {
- const host = _getVibeWidgetHost();
- if (!host) return null;
- let panel = host.querySelector('.chat-vibe-widget');
- if (!panel) {
- panel = document.createElement('section');
- panel.className = 'chat-vibe-widget';
- panel.innerHTML = '<div class="chat-vibe-widget-head"><div class="chat-vibe-widget-title"><i class="fa-solid fa-code"></i> <span>Vibe Coding</span></div><div class="chat-vibe-widget-actions"><div class="chat-vibe-widget-status">Live</div><button type="button" class="chat-vibe-widget-toggle" aria-expanded="true">Hide</button></div></div><div class="chat-vibe-widget-feed" tabindex="0" aria-label="Vibe coding event feed"></div><button type="button" class="chat-vibe-new-msgs-indicator is-hidden" aria-hidden="true"><i class="fa-solid fa-arrow-down"></i> New messages below</button>';
- const header = host.querySelector('.chat-header, .global-chat-header');
- if (header) header.after(panel);
- else host.prepend(panel);
- }
- if (panel.dataset.vibeWidgetBound !== '1') {
- const toggle = panel.querySelector('.chat-vibe-widget-toggle');
- if (toggle) {
- toggle.addEventListener('click', () => {
- _toggleVibeWidgetCollapsed();
- });
- }
- panel.dataset.vibeWidgetBound = '1';
- }
- _vibeWidgetState.panel = panel;
- _vibeWidgetState.feed = panel.querySelector('.chat-vibe-widget-feed');
- _vibeWidgetState.indicator = panel.querySelector('.chat-vibe-new-msgs-indicator');
- if (_vibeWidgetState.feed && _vibeWidgetState.feed.dataset.vibeWidgetScrollBound !== '1') {
- _vibeWidgetState.feed.dataset.vibeWidgetScrollBound = '1';
- _vibeWidgetState.feed.addEventListener('scroll', () => {
- _syncVibeWidgetScrollState();
- }, { passive: true });
- _vibeWidgetState.feed.addEventListener('toggle', (event) => {
- const details = event.target.closest('details');
- if (!details || !details.dataset.vibeDisclosureKey) return;
- if (details.open) {
- _vibeWidgetState.openDisclosures.add(details.dataset.vibeDisclosureKey);
- } else {
- _vibeWidgetState.openDisclosures.delete(details.dataset.vibeDisclosureKey);
- }
- });
- }
- if (_vibeWidgetState.indicator && _vibeWidgetState.indicator.dataset.vibeWidgetIndicatorBound !== '1') {
- _vibeWidgetState.indicator.dataset.vibeWidgetIndicatorBound = '1';
- _vibeWidgetState.indicator.addEventListener('click', () => {
- _scrollVibeWidgetToBottom();
- });
- }
- return panel;
+function _removeFeedCard() {
+ _feedCard.seq++;
+ if (_feedCard.frame) _feedCard.frame.remove();
+ _feedCard.frame = null;
+ _feedCard.key = null;
}
}
-function _getVibeWidgetFetchLimit() {
- return Math.max(VIBE_WIDGET_FALLBACK_MAX, parseInt(_vibeWidgetState.settings?.max_events, 10) || 0);
+function _feedCardTheme() {
+ const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(getComputedStyle(document.body).backgroundColor || '');
+ return m && (0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3]) > 140 ? 'light' : 'dark';
}
}
-function _getVibeWidgetEventKey(event) {
- if (!event) return '';
- return String(
- event.eventId
- || [event.sessionId || '', event.sequence || '', event.eventType || '', event.summary || '', event.occurredAt || ''].join(':')
- );
-}
-
-function _sortVibeWidgetEvents(events) {
- return (Array.isArray(events) ? events.slice() : []).sort((left, right) => {
- const leftSeq = Number(left?.sequence || 0);
- const rightSeq = Number(right?.sequence || 0);
- if (leftSeq && rightSeq && leftSeq !== rightSeq) {
- return leftSeq - rightSeq;
- }
-
- const leftTime = Date.parse(left?.occurredAt || '') || 0;
- const rightTime = Date.parse(right?.occurredAt || '') || 0;
- if (leftTime !== rightTime) {
- return leftTime - rightTime;
- }
-
- return _getVibeWidgetEventKey(left).localeCompare(_getVibeWidgetEventKey(right));
- });
-}
-
-function _replaceVibeWidgetEvents(events) {
- const deduped = [];
- const seen = new Set();
- _sortVibeWidgetEvents(events).forEach((event) => {
- const key = _getVibeWidgetEventKey(event);
- if (!key || seen.has(key)) return;
- seen.add(key);
- deduped.push(event);
- });
- const maxStored = Math.max(_getVibeWidgetFetchLimit() * 3, VIBE_WIDGET_FALLBACK_MAX);
- _vibeWidgetState.events = deduped.slice(-maxStored);
-}
-
-function _mergeVibeWidgetEvents(events) {
- const existingKeys = new Set(_vibeWidgetState.events.map((event) => _getVibeWidgetEventKey(event)));
- const nextEvents = _vibeWidgetState.events.slice();
- let added = 0;
- (Array.isArray(events) ? events : []).forEach((event) => {
- const key = _getVibeWidgetEventKey(event);
- if (!key || existingKeys.has(key)) return;
- existingKeys.add(key);
- nextEvents.push(event);
- added += 1;
- });
- if (!added) return 0;
- _replaceVibeWidgetEvents(nextEvents);
- return added;
-}
-
-function _isVibeWidgetNearBottom() {
- const feed = _vibeWidgetState.feed;
- if (!feed) return true;
- return (feed.scrollHeight - feed.scrollTop - feed.clientHeight) < VIBE_WIDGET_SCROLL_THRESHOLD;
-}
-
-function _showVibeWidgetNewMessagesIndicator() {
- const indicator = _vibeWidgetState.indicator;
- if (!indicator || _vibeWidgetState.collapsed || !_vibeWidgetState.unreadCount) return;
- indicator.innerHTML = `<i class="fa-solid fa-arrow-down"></i> ${_vibeWidgetState.unreadCount} new message${_vibeWidgetState.unreadCount !== 1 ? 's' : ''} below`;
- indicator.style.display = '';
- indicator.classList.remove('is-hidden');
- indicator.classList.add('is-visible');
- indicator.setAttribute('aria-hidden', 'false');
-}
-
-function _hideVibeWidgetNewMessagesIndicator() {
- if (_vibeWidgetState.indicator) {
- _vibeWidgetState.indicator.style.display = '';
- _vibeWidgetState.indicator.classList.remove('is-visible');
- _vibeWidgetState.indicator.classList.add('is-hidden');
- _vibeWidgetState.indicator.setAttribute('aria-hidden', 'true');
- }
-}
-
-function _scrollVibeWidgetToBottom() {
- const feed = _vibeWidgetState.feed;
- if (!feed) return;
- feed.scrollTop = feed.scrollHeight;
- _vibeWidgetState.userScrolledUp = false;
- _vibeWidgetState.unreadCount = 0;
- _hideVibeWidgetNewMessagesIndicator();
-}
-
-function _syncVibeWidgetScrollState() {
- if (_isVibeWidgetNearBottom()) {
- _vibeWidgetState.userScrolledUp = false;
- _vibeWidgetState.unreadCount = 0;
- _hideVibeWidgetNewMessagesIndicator();
- return;
- }
- _vibeWidgetState.userScrolledUp = true;
-}
-
-function _ensureVibeWidgetRefreshTimer() {
- if (_vibeWidgetState.refreshTimer) return;
- _vibeWidgetState.refreshTimer = setInterval(() => {
- if (!_vibeWidgetState.key || document.hidden) return;
- refreshVibeWidgetFeed().catch(() => { });
- }, VIBE_WIDGET_REFRESH_MS);
-}
-
-function _formatVibeEventTime(value) {
- const time = Date.parse(value || '');
- if (!time) return '';
+async function refreshFeedCard() {
+ const context = _feedCardContext();
+ if (!context) { _removeFeedCard(); return; }
+ const key = String(context.managedStreamId);
+ if (_feedCard.key === key && (!_feedCard.frame || _feedCard.frame.isConnected)) return;
+ _removeFeedCard();
+ _feedCard.key = key;
+ const seq = _feedCard.seq;
+ let feed = null;
try {
try {
- return new Date(time).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
- } catch {
- return '';
- }
-}
-
-function _normalizeVibeDisplayText(value) {
- return String(value || '')
- .replace(/\r/g, ' ')
- .replace(/`+/g, '')
- .replace(/\binlineReference\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, 'reference')
- .replace(/\s*#{1,6}\s*/g, ' ')
- .replace(/\s+([.,;:!?])/g, '$1')
- .replace(/\s+/g, ' ')
- .trim();
-}
-
-function _truncateVibeDisplayText(value, maxChars = 84) {
- const text = _normalizeVibeDisplayText(value);
- if (!text) return '';
- if (text.length <= maxChars) return text;
- return `${text.slice(0, Math.max(24, maxChars - 1)).trim()}…`;
-}
-
-function _splitVibeNarrative(value, maxSegments = 3) {
- const text = _normalizeVibeDisplayText(value);
- if (!text) return [];
- const parts = text
- .split(/(?<=[.!?])\s+(?=(?:[A-Z0-9✅❌•-]))/g)
- .map((part) => _normalizeVibeDisplayText(part))
- .filter(Boolean);
- if (parts.length <= 1) return [text];
- if (parts.length <= maxSegments) return parts;
- const tailCount = Math.max(1, maxSegments - 1);
- const head = _normalizeVibeDisplayText(parts.slice(0, parts.length - tailCount).join(' '));
- return [head, ...parts.slice(-tailCount)].filter(Boolean);
-}
-
-function _extractVibeListParts(value) {
- const text = _normalizeVibeDisplayText(value);
- if (!text || !/\s[•-]\s/.test(text) && !/^[•-]\s/.test(text)) {
- return { lead: text, items: [] };
- }
- const startsWithBullet = /^[•-]\s/.test(text);
- const normalized = startsWithBullet ? text.replace(/^[•-]\s+/, '') : text;
- const parts = normalized
- .split(/\s+[•-]\s+/g)
- .map((part) => _normalizeVibeDisplayText(part))
- .filter(Boolean);
- if (parts.length < 2) {
- return { lead: text, items: [] };
- }
- if (startsWithBullet) {
- return { lead: '', items: parts };
- }
- return { lead: parts[0], items: parts.slice(1) };
-}
-
-function _pushUniqueVibeBlock(blocks, blockText) {
- const text = _normalizeVibeDisplayText(blockText);
- if (!text) return;
- const normalized = text.toLowerCase();
- const exactIndex = blocks.findIndex((block) => block.normalized === normalized);
- if (exactIndex >= 0) return;
- const containingIndex = blocks.findIndex((block) => text.includes(block.text) && text.length > block.text.length);
- if (containingIndex >= 0) {
- blocks[containingIndex] = { text, normalized };
- for (let index = blocks.length - 1; index >= 0; index -= 1) {
- if (index === containingIndex) continue;
- if (text.includes(blocks[index].text)) {
- blocks.splice(index, 1);
- }
- }
- return;
- }
- if (blocks.some((block) => block.text.includes(text))) return;
- blocks.push({ text, normalized });
-}
-
-function _createVibeTurn(event, key) {
- const occurredAt = event?.occurredAt || '';
- return {
- kind: 'turn',
- key,
- occurredAt,
- lastAt: occurredAt,
- promptText: '',
- responseBlocks: [],
- thinkingBlocks: [],
- sourceLabel: _normalizeVibeDisplayText(event?.publisher?.integrationLabel || ''),
- tools: new Map(),
- files: new Map(),
- };
-}
-
-function _isVibeExecutionToolName(toolName) {
- const normalized = _normalizeVibeDisplayText(toolName).toLowerCase();
- return normalized === 'run_in_terminal'
- || normalized === 'create_and_run_task'
- || normalized === 'run_notebook_cell';
-}
-
-function _hasMeaningfulVibeToolPreview(tool) {
- const preview = _normalizeVibeDisplayText(tool?.preview || '');
- if (!preview) return false;
- const normalizedName = _normalizeVibeDisplayText(tool?.name || '').toLowerCase();
- return preview.toLowerCase() !== `used ${normalizedName}`;
-}
-
-function _formatVibeToolLabel(toolName) {
- const normalized = _normalizeVibeDisplayText(toolName);
- if (!normalized) return 'Tool';
- return normalized
- .replace(/[._-]+/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- .replace(/\b\w/g, (char) => char.toUpperCase());
-}
-
-function _canReuseLoosePromptTurn(turn, promptText, event) {
- if (!turn || !promptText) return false;
- if (_normalizeVibeDisplayText(turn.promptText).toLowerCase() !== promptText.toLowerCase()) {
- return false;
- }
- return _canMergeLooseVibeEvent(turn, event);
-}
-
-function _claimPendingVibePromptTurn(pendingPromptTurns, event) {
- for (let index = 0; index < pendingPromptTurns.length; index += 1) {
- const candidate = pendingPromptTurns[index];
- if (!_canMergeLooseVibeEvent(candidate, event)) continue;
- pendingPromptTurns.splice(index, 1);
- return candidate;
- }
- return null;
-}
-
-function _addVibeToolToTurn(turn, event) {
- const toolName = _normalizeVibeDisplayText(event?.tool?.name || event?.summary || 'tool');
- if (!toolName) return;
- const existing = turn.tools.get(toolName);
- const argumentsPreview = _normalizeVibeDisplayText(event?.tool?.argumentsPreview || '');
- const resultPreview = _normalizeVibeDisplayText(event?.tool?.resultPreview || '');
- const preview = [
- argumentsPreview,
- resultPreview,
- event?.summary,
- ]
- .map((value) => _normalizeVibeDisplayText(value))
- .find((value) => value && value.toLowerCase() !== toolName.toLowerCase()) || '';
- turn.tools.set(toolName, {
- name: toolName,
- label: _formatVibeToolLabel(toolName),
- count: (existing?.count || 0) + 1,
- argumentsPreview: argumentsPreview || existing?.argumentsPreview || '',
- resultPreview: resultPreview || existing?.resultPreview || '',
- preview: preview || existing?.preview || '',
- isExecution: _isVibeExecutionToolName(toolName),
- });
-}
-
-function _addVibeFileToTurn(turn, event) {
- const fileName = _normalizeVibeDisplayText(event?.file?.relativePath || event?.file?.name || event?.summary || 'file');
- if (!fileName) return;
- const existing = turn.files.get(fileName);
- const operation = event?.file?.operation === 'save' ? 'save' : 'edit';
- const changeCount = Math.max(0, Number(event?.file?.changeCount || 0));
- const snippet = _normalizeVibeDisplayText(event?.file?.snippet || '');
- turn.files.set(fileName, {
- name: fileName,
- count: (existing?.count || 0) + 1,
- changeCount: (existing?.changeCount || 0) + changeCount,
- edited: !!existing?.edited || operation === 'edit',
- saved: !!existing?.saved || operation === 'save',
- snippet: snippet || existing?.snippet || '',
- });
-}
-
-function _getExplicitVibeTurnKey(event) {
- const requestId = _normalizeVibeDisplayText(event?.metadata?.requestId);
- const responseId = _normalizeVibeDisplayText(event?.metadata?.responseId);
- if (requestId || responseId) {
- return `turn:${requestId || responseId}:${responseId || ''}`;
- }
- return '';
-}
-
-function _ensureVibeTurn(items, turns, key, event) {
- let turn = turns.get(key);
- if (turn) return turn;
- turn = _createVibeTurn(event, key);
- turns.set(key, turn);
- items.push(turn);
- return turn;
-}
-
-function _canMergeLooseVibeEvent(turn, event) {
- if (!turn || !event || event?.eventType === 'prompt' || event?.eventType === 'session.status') {
- return false;
- }
- const turnTime = Date.parse(turn.lastAt || turn.occurredAt || '') || 0;
- const eventTime = Date.parse(event?.occurredAt || '') || 0;
- if (turnTime && eventTime && Math.abs(eventTime - turnTime) > (5 * 60 * 1000)) {
- return false;
- }
- return true;
-}
-
-function _getVibeTurnTimeValue(turn, preferLast = false) {
- if (!turn) return 0;
- const preferred = Date.parse(preferLast ? (turn.lastAt || turn.occurredAt || '') : (turn.occurredAt || turn.lastAt || '')) || 0;
- if (preferred) return preferred;
- return Date.parse(turn.occurredAt || turn.lastAt || '') || 0;
-}
-
-function _getVibeTurnTextValues(turn) {
- const values = [];
- if (turn?.promptText) values.push(_normalizeVibeDisplayText(turn.promptText));
- (Array.isArray(turn?.responseBlocks) ? turn.responseBlocks : []).forEach((block) => values.push(_normalizeVibeDisplayText(block?.text || block)));
- (Array.isArray(turn?.thinkingBlocks) ? turn.thinkingBlocks : []).forEach((block) => values.push(_normalizeVibeDisplayText(block?.text || block)));
- return values.filter(Boolean);
-}
-
-function _getVibeTextTokenCount(leftText, rightText) {
- const leftTokens = Array.from(new Set(_normalizeVibeDisplayText(leftText).toLowerCase().split(/[^a-z0-9]+/g).filter((token) => token.length >= 4)));
- if (!leftTokens.length) return 0;
- const rightTokens = new Set(_normalizeVibeDisplayText(rightText).toLowerCase().split(/[^a-z0-9]+/g).filter((token) => token.length >= 4));
- return leftTokens.reduce((count, token) => count + (rightTokens.has(token) ? 1 : 0), 0);
-}
-
-function _getVibeTurnOverlapScore(sourceTurn, targetTurn) {
- const sourceTexts = _getVibeTurnTextValues(sourceTurn);
- const targetTexts = _getVibeTurnTextValues(targetTurn);
- let bestScore = 0;
- sourceTexts.forEach((sourceText) => {
- targetTexts.forEach((targetText) => {
- if (!sourceText || !targetText) return;
- if (sourceText === targetText) {
- bestScore = Math.max(bestScore, 100);
- return;
- }
- if (targetText.includes(sourceText) || sourceText.includes(targetText)) {
- bestScore = Math.max(bestScore, 60);
- return;
- }
- bestScore = Math.max(bestScore, _getVibeTextTokenCount(sourceText, targetText));
- });
- });
- return bestScore;
-}
-
-function _mergeVibeBlockHistory(existingBlocks, incomingBlocks, placeIncomingFirst) {
- const ordered = placeIncomingFirst
- ? [...(incomingBlocks || []), ...(existingBlocks || [])]
- : [...(existingBlocks || []), ...(incomingBlocks || [])];
- const merged = [];
- ordered.forEach((block) => {
- const text = _normalizeVibeDisplayText(block?.text || block);
- if (!text) return;
- const normalized = text.toLowerCase();
- if (merged.some((item) => item.normalized === normalized)) return;
- merged.push({ text, normalized });
- });
- return merged;
-}
-
-function _mergeVibeTurnIntoTarget(targetTurn, sourceTurn) {
- if (!targetTurn || !sourceTurn || targetTurn === sourceTurn) return;
- const sourceTime = _getVibeTurnTimeValue(sourceTurn, true);
- const targetStart = _getVibeTurnTimeValue(targetTurn, false);
- const placeIncomingFirst = !!sourceTime && !!targetStart && sourceTime <= targetStart;
-
- targetTurn.responseBlocks = _mergeVibeBlockHistory(targetTurn.responseBlocks, sourceTurn.responseBlocks, placeIncomingFirst);
- targetTurn.thinkingBlocks = _mergeVibeBlockHistory(targetTurn.thinkingBlocks, sourceTurn.thinkingBlocks, placeIncomingFirst);
-
- sourceTurn.tools.forEach((tool, toolName) => {
- const existing = targetTurn.tools.get(toolName);
- targetTurn.tools.set(toolName, {
- name: tool.name,
- label: existing?.label || tool?.label || _formatVibeToolLabel(tool.name),
- count: (existing?.count || 0) + (tool?.count || 0),
- argumentsPreview: existing?.argumentsPreview || tool?.argumentsPreview || '',
- resultPreview: existing?.resultPreview || tool?.resultPreview || '',
- preview: existing?.preview || tool?.preview || '',
- isExecution: !!existing?.isExecution || !!tool?.isExecution,
- });
- });
-
- sourceTurn.files.forEach((file, fileName) => {
- const existing = targetTurn.files.get(fileName);
- targetTurn.files.set(fileName, {
- name: file.name,
- count: (existing?.count || 0) + (file?.count || 0),
- changeCount: (existing?.changeCount || 0) + (file?.changeCount || 0),
- edited: !!existing?.edited || !!file?.edited,
- saved: !!existing?.saved || !!file?.saved,
- snippet: existing?.snippet || file?.snippet || '',
- });
- });
-
- if (!targetTurn.sourceLabel && sourceTurn.sourceLabel) {
- targetTurn.sourceLabel = sourceTurn.sourceLabel;
- }
- if (!targetTurn.occurredAt || (sourceTurn.occurredAt && Date.parse(sourceTurn.occurredAt) < Date.parse(targetTurn.occurredAt))) {
- targetTurn.occurredAt = sourceTurn.occurredAt || targetTurn.occurredAt;
- }
- if (!targetTurn.lastAt || (sourceTurn.lastAt && Date.parse(sourceTurn.lastAt) > Date.parse(targetTurn.lastAt))) {
- targetTurn.lastAt = sourceTurn.lastAt || targetTurn.lastAt;
- }
-}
-
-function _getVibeLooseMergeScore(sourceTurn, targetTurn) {
- if (!sourceTurn || !targetTurn || sourceTurn === targetTurn || !targetTurn.promptText || sourceTurn.promptText) {
- return -1;
- }
-
- const sourceTime = _getVibeTurnTimeValue(sourceTurn, true);
- const targetStart = _getVibeTurnTimeValue(targetTurn, false);
- const targetEnd = _getVibeTurnTimeValue(targetTurn, true) || targetStart;
- if (sourceTime && targetStart && (sourceTime < (targetStart - (3 * 60 * 1000)) || sourceTime > (targetEnd + (4 * 60 * 1000)))) {
- return -1;
- }
-
- let score = _getVibeTurnOverlapScore(sourceTurn, targetTurn);
- if ((sourceTurn.tools.size || sourceTurn.files.size) && sourceTime && targetStart) {
- score += sourceTime >= (targetStart - (60 * 1000)) ? 25 : 10;
- }
-
- if (sourceTime && targetEnd) {
- const distance = Math.abs(sourceTime - targetEnd);
- score += Math.max(0, 12 - Math.floor(distance / 30000));
- }
-
- return score;
-}
-
-function _normalizeVibeWidgetDisplayItems(items) {
- const turns = items.filter((item) => item?.kind === 'turn');
- const removedKeys = new Set();
-
- turns.forEach((turn) => {
- if (turn.promptText) return;
- let bestTarget = null;
- let bestScore = -1;
- turns.forEach((candidate) => {
- const score = _getVibeLooseMergeScore(turn, candidate);
- if (score > bestScore) {
- bestScore = score;
- bestTarget = candidate;
- }
- });
-
- if (bestTarget && bestScore >= 8) {
- _mergeVibeTurnIntoTarget(bestTarget, turn);
- removedKeys.add(turn.key);
- }
- });
-
- return items
- .filter((item) => !removedKeys.has(item?.key))
- .sort((left, right) => {
- const leftTime = left?.kind === 'turn'
- ? _getVibeTurnTimeValue(left, false)
- : (Date.parse(left?.occurredAt || '') || 0);
- const rightTime = right?.kind === 'turn'
- ? _getVibeTurnTimeValue(right, false)
- : (Date.parse(right?.occurredAt || '') || 0);
- if (leftTime !== rightTime) return leftTime - rightTime;
- return String(left?.key || '').localeCompare(String(right?.key || ''));
- });
-}
-
-function _buildVibeWidgetDisplayItems(events) {
- const items = [];
- const turns = new Map();
- const pendingPromptTurns = [];
- let activeLooseTurn = null;
- let lastLoosePromptTurn = null;
- let looseCounter = 0;
-
- _sortVibeWidgetEvents(events).forEach((event, index) => {
- if (event?.eventType === 'session.status') {
- items.push({
- kind: 'system',
- key: `system:${_getVibeWidgetEventKey(event) || index}`,
- occurredAt: event?.occurredAt || '',
- text: _normalizeVibeDisplayText(event?.summary || 'Copilot session update'),
- });
- activeLooseTurn = null;
- lastLoosePromptTurn = null;
- return;
- }
-
- const explicitKey = _getExplicitVibeTurnKey(event);
- let turn = null;
- if (event?.eventType === 'prompt') {
- const promptText = _normalizeVibeDisplayText(event?.prompt?.text || event?.summary);
- if (!explicitKey && _canReuseLoosePromptTurn(lastLoosePromptTurn, promptText, event)) {
- turn = lastLoosePromptTurn;
- } else {
- turn = _ensureVibeTurn(items, turns, explicitKey || `prompt:${_getVibeWidgetEventKey(event) || index}`, event);
- }
- activeLooseTurn = turn;
- if (!explicitKey && !pendingPromptTurns.includes(turn)
- && !turn.responseBlocks.length
- && !turn.thinkingBlocks.length
- && !turn.tools.size
- && !turn.files.size) {
- pendingPromptTurns.push(turn);
- }
- lastLoosePromptTurn = explicitKey ? null : turn;
- } else if (explicitKey) {
- turn = _ensureVibeTurn(items, turns, explicitKey, event);
- activeLooseTurn = turn;
- lastLoosePromptTurn = null;
- } else {
- const claimedPromptTurn = _claimPendingVibePromptTurn(pendingPromptTurns, event);
- if (claimedPromptTurn) {
- turn = claimedPromptTurn;
- } else if (_canMergeLooseVibeEvent(activeLooseTurn, event)) {
- turn = activeLooseTurn;
- } else {
- turn = _ensureVibeTurn(items, turns, `loose-group:${looseCounter += 1}:${_getVibeWidgetEventKey(event) || index}`, event);
- }
- activeLooseTurn = turn;
- }
-
- if (event?.occurredAt) {
- if (!turn.occurredAt || Date.parse(event.occurredAt) < Date.parse(turn.occurredAt)) {
- turn.occurredAt = event.occurredAt;
- }
- if (!turn.lastAt || Date.parse(event.occurredAt) > Date.parse(turn.lastAt)) {
- turn.lastAt = event.occurredAt;
- }
- }
- if (!turn.sourceLabel && event?.publisher?.integrationLabel) {
- turn.sourceLabel = _normalizeVibeDisplayText(event.publisher.integrationLabel);
- }
-
- if (event?.eventType === 'prompt') {
- const promptText = _normalizeVibeDisplayText(event?.prompt?.text || event?.summary);
- if (promptText) turn.promptText = promptText;
- return;
- }
-
- if (event?.eventType === 'response') {
- _pushUniqueVibeBlock(turn.responseBlocks, event?.response?.text || event?.summary);
- return;
- }
-
- if (event?.eventType === 'thinking') {
- _pushUniqueVibeBlock(turn.thinkingBlocks, event?.thinking?.text || event?.summary);
- return;
- }
-
- if (event?.eventType === 'tool.call') {
- _addVibeToolToTurn(turn, event);
- return;
- }
-
- if (event?.eventType === 'file.change' || event?.eventType === 'file.save') {
- _addVibeFileToTurn(turn, event);
- }
- });
-
- const filteredItems = items.filter((item) => item.kind === 'system'
- || item.promptText
- || item.responseBlocks.length
- || item.thinkingBlocks.length
- || item.tools.size
- || item.files.size);
-
- return _normalizeVibeWidgetDisplayItems(filteredItems);
-}
-
-function _renderVibeDisclosure(kind, label, preview, meta, bodyHtml, disclosureKey) {
- if (!bodyHtml) return '';
- const disclosureId = disclosureKey ? ` data-vibe-disclosure-key="${esc(disclosureKey)}"` : '';
- const isOpen = disclosureKey && _vibeWidgetState.openDisclosures.has(disclosureKey);
- return `<details class="chat-vibe-turn-disclosure chat-vibe-turn-disclosure-${esc(kind)}"${disclosureId}${isOpen ? ' open' : ''}><summary><span class="chat-vibe-turn-disclosure-kicker">${esc(label)}</span><span class="chat-vibe-turn-disclosure-title">${esc(preview || label)}</span>${meta ? `<span class="chat-vibe-turn-disclosure-meta">${esc(meta)}</span>` : ''}</summary><div class="chat-vibe-turn-disclosure-body">${bodyHtml}</div></details>`;
-}
-
-function _getVibeWidgetItemDomKey(item, index) {
- return String(item?.key || `vibe-item:${index}`);
-}
-
-function _createVibeWidgetItemNode(item, itemKey) {
- const html = item.kind === 'system' ? _renderVibeSystemItem(item) : _renderVibeTurnItem(item);
- const wrapper = document.createElement('div');
- wrapper.innerHTML = html;
- const node = wrapper.firstElementChild;
- if (!node) return null;
- node.dataset.vibeKey = itemKey;
- return node;
-}
-
-function _renderVibeParagraphs(text, maxSegments = 3, muted = false) {
- return _splitVibeNarrative(text, maxSegments)
- .map((part) => `<p class="chat-vibe-turn-paragraph${muted ? ' is-muted' : ''}">${esc(part)}</p>`)
- .join('');
-}
-
-function _renderVibeRichText(text, maxSegments = 3, muted = false) {
- const { lead, items } = _extractVibeListParts(text);
- if (!items.length) {
- return _renderVibeParagraphs(text, maxSegments, muted);
- }
- const leadHtml = lead ? _renderVibeParagraphs(lead, Math.max(1, maxSegments - 1), muted) : '';
- const listHtml = `<ul class="chat-vibe-turn-bullets">${items.map((item) => `<li class="chat-vibe-turn-list-item${muted ? ' is-muted' : ''}">${esc(item)}</li>`).join('')}</ul>`;
- return `${leadHtml}${listHtml}`;
-}
-
-function _renderVibeSystemItem(item) {
- const timestamp = _formatVibeEventTime(item?.occurredAt);
- return `<div class="chat-vibe-system">${timestamp ? `<span class="chat-vibe-system-time">${esc(timestamp)}</span>` : ''}<span class="chat-vibe-system-text">${esc(item?.text || 'Copilot session update')}</span></div>`;
-}
-
-function _renderVibeTurnItem(turn) {
- const hasSecondarySections = turn.thinkingBlocks.length || turn.tools.size || turn.files.size;
- const turnClasses = ['chat-vibe-turn'];
- if (!turn.promptText) turnClasses.push('is-compact');
- if (!turn.promptText && !hasSecondarySections) turnClasses.push('is-update');
- const promptHtml = turn.promptText
- ? `<div class="chat-vibe-turn-prompt-row"><div class="chat-vibe-turn-prompt">${esc(turn.promptText)}</div></div>`
- : '';
- const responseBlocks = Array.isArray(turn.responseBlocks) ? turn.responseBlocks : [];
- const primaryResponse = responseBlocks.length ? responseBlocks[responseBlocks.length - 1].text : '';
- let progressBlocks = responseBlocks.slice(0, -1);
- let thinkingBlocks = turn.thinkingBlocks.slice();
- let visibleFallbackText = '';
- let fallbackMuted = false;
-
- if (!primaryResponse && progressBlocks.length) {
- visibleFallbackText = progressBlocks[progressBlocks.length - 1].text;
- progressBlocks = progressBlocks.slice(0, -1);
- fallbackMuted = false;
- } else if (!primaryResponse && thinkingBlocks.length) {
- visibleFallbackText = thinkingBlocks[thinkingBlocks.length - 1].text;
- thinkingBlocks = thinkingBlocks.slice(0, -1);
- fallbackMuted = true;
- }
-
- const responseHtml = primaryResponse
- ? _renderVibeRichText(primaryResponse, 3, false)
- : visibleFallbackText
- ? _renderVibeRichText(visibleFallbackText, 2, fallbackMuted)
- : '<p class="chat-vibe-turn-paragraph is-muted">Working…</p>';
-
- const disclosureBase = turn.key || `turn:${turn.occurredAt || turn.lastAt || Date.now()}`;
- const progressHtml = progressBlocks.length
- ? _renderVibeDisclosure(
- 'progress',
- 'Progress',
- _truncateVibeDisplayText(progressBlocks[progressBlocks.length - 1].text, 90),
- `${progressBlocks.length} update${progressBlocks.length === 1 ? '' : 's'}`,
- progressBlocks.map((block) => `<p class="chat-vibe-turn-note">${esc(block.text)}</p>`).join(''),
- `${disclosureBase}:progress`
- )
- : '';
-
- const thinkingHtml = thinkingBlocks.length
- ? _renderVibeDisclosure(
- 'thinking',
- 'Thinking',
- _truncateVibeDisplayText(thinkingBlocks[thinkingBlocks.length - 1].text, 90),
- `${thinkingBlocks.length} note${thinkingBlocks.length === 1 ? '' : 's'}`,
- thinkingBlocks.map((block) => `<p class="chat-vibe-turn-note">${esc(block.text)}</p>`).join(''),
- `${disclosureBase}:thinking`
- )
- : '';
-
- const toolEntries = Array.from(turn.tools.values()).sort((left, right) => {
- if ((right?.count || 0) !== (left?.count || 0)) {
- return (right?.count || 0) - (left?.count || 0);
- }
- return String(left?.name || '').localeCompare(String(right?.name || ''));
- });
- const commandEntries = toolEntries.filter((tool) => tool.isExecution);
- const otherToolEntries = toolEntries.filter((tool) => !tool.isExecution);
- const commandTotal = commandEntries.reduce((sum, tool) => sum + (tool.count || 0), 0);
- const commandHtml = commandEntries.length
- ? _renderVibeDisclosure(
- 'commands',
- 'Commands',
- _truncateVibeDisplayText(commandEntries[commandEntries.length - 1].argumentsPreview || commandEntries[commandEntries.length - 1].preview || commandEntries[0].name, 96),
- `${commandTotal} run${commandTotal === 1 ? '' : 's'}`,
- commandEntries.map((tool) => {
- const subtitle = tool.resultPreview
- ? 'Completed execution'
- : tool.argumentsPreview
- ? 'Ran command'
- : 'Executed tool';
- return `
- <div class="chat-vibe-turn-file-card">
- <div class="chat-vibe-turn-list-row">
- <div class="chat-vibe-turn-list-main">
- <div class="chat-vibe-turn-list-title">${esc(tool.label || tool.name)}</div>
- <div class="chat-vibe-turn-list-subtitle">${esc(subtitle)}</div>
- </div>
- <div class="chat-vibe-turn-pill-group"><span class="chat-vibe-turn-pill is-count">${esc(tool.count > 1 ? `x${tool.count}` : '1x')}</span></div>
- </div>
- ${tool.argumentsPreview ? `<p class="chat-vibe-turn-note chat-vibe-turn-note-mono is-command">${esc(_truncateVibeDisplayText(tool.argumentsPreview, 180))}</p>` : ''}
- ${tool.resultPreview ? `<p class="chat-vibe-turn-note">${esc(_truncateVibeDisplayText(tool.resultPreview, 180))}</p>` : ''}
- </div>`;
- }).join(''),
- `${disclosureBase}:commands`
- )
- : '';
- const toolTotal = otherToolEntries.reduce((sum, tool) => sum + (tool.count || 0), 0);
- const toolsHtml = otherToolEntries.length
- ? _renderVibeDisclosure(
- 'tools',
- 'Tools',
- otherToolEntries.slice(0, 2).map((tool) => tool.label || tool.name).join(', '),
- `${toolTotal} call${toolTotal === 1 ? '' : 's'}`,
- otherToolEntries.map((tool) => {
- const subtitle = _hasMeaningfulVibeToolPreview(tool)
- ? _truncateVibeDisplayText(tool.preview, 120)
- : '';
- return `
- <div class="chat-vibe-turn-list-row">
- <div class="chat-vibe-turn-list-main">
- <div class="chat-vibe-turn-list-title">${esc(tool.label || tool.name)}</div>
- ${subtitle ? `<div class="chat-vibe-turn-list-subtitle">${esc(subtitle)}</div>` : ''}
- </div>
- <div class="chat-vibe-turn-pill-group"><span class="chat-vibe-turn-pill is-count">${esc(tool.count > 1 ? `x${tool.count}` : '1x')}</span></div>
- </div>`;
- }).join(''),
- `${disclosureBase}:tools`
- )
- : '';
-
- const fileEntries = Array.from(turn.files.values()).sort((left, right) => {
- if ((right?.changeCount || 0) !== (left?.changeCount || 0)) {
- return (right?.changeCount || 0) - (left?.changeCount || 0);
- }
- return String(left?.name || '').localeCompare(String(right?.name || ''));
- });
- const filesHtml = fileEntries.length
- ? _renderVibeDisclosure(
- 'files',
- 'Edited files',
- fileEntries.slice(0, 2).map((file) => file.name).join(', '),
- `${fileEntries.length} file${fileEntries.length === 1 ? '' : 's'}`,
- fileEntries.map((file) => {
- const badges = [];
- if (file.changeCount > 0) {
- badges.push(`<span class="chat-vibe-turn-pill is-edit">${esc(`${file.changeCount} edit${file.changeCount === 1 ? '' : 's'}`)}</span>`);
- }
- if (file.saved) {
- badges.push('<span class="chat-vibe-turn-pill is-save">saved</span>');
- }
- if (!badges.length && file.edited) {
- badges.push('<span class="chat-vibe-turn-pill is-edit">edited</span>');
- }
- return `
- <div class="chat-vibe-turn-file-card">
- <div class="chat-vibe-turn-list-row">
- <div class="chat-vibe-turn-list-main">
- <div class="chat-vibe-turn-list-title is-mono">${esc(file.name)}</div>
- <div class="chat-vibe-turn-list-subtitle">${esc(file.saved && file.edited ? 'Edited and saved during this turn' : file.saved ? 'Saved in workspace' : 'Edited in workspace')}</div>
- </div>
- <div class="chat-vibe-turn-pill-group">${badges.join('')}</div>
- </div>
- ${file.snippet ? `<p class="chat-vibe-turn-note chat-vibe-turn-note-mono">${esc(_truncateVibeDisplayText(file.snippet, 180))}</p>` : ''}
- </div>`;
- }).join(''),
- `${disclosureBase}:files`
- )
- : '';
-
- return `
- <article class="${turnClasses.join(' ')}">
- ${promptHtml}
- <div class="chat-vibe-turn-response">
- <div class="chat-vibe-turn-response-top">
- <span class="chat-vibe-turn-time">${esc(_formatVibeEventTime(turn.lastAt || turn.occurredAt))}</span>
- </div>
- <div class="chat-vibe-turn-body">${responseHtml}</div>
- ${progressHtml}
- ${thinkingHtml}
- ${commandHtml}
- ${toolsHtml}
- ${filesHtml}
- </div>
- </article>`;
-}
-
-function _renderVibeWidget() {
- const panel = _ensureVibeWidgetPanel();
- if (!panel || !_vibeWidgetState.feed) return;
- const settings = _vibeWidgetState.settings;
- if (!settings?.enabled) {
- _resetVibeWidget(true);
- return;
- }
-
- panel.style.display = '';
- panel.classList.toggle('is-collapsed', !!_vibeWidgetState.collapsed);
- const titleEl = panel.querySelector('.chat-vibe-widget-title span');
- if (titleEl) titleEl.textContent = settings.widget_title || 'Vibe Coding';
- const statusEl = panel.querySelector('.chat-vibe-widget-status');
- if (statusEl) {
- const shouldShowBadge = _vibeWidgetState.collapsed && _vibeWidgetState.unreadCount > 0;
- statusEl.classList.toggle('is-badge', shouldShowBadge);
- statusEl.classList.toggle('is-hidden', _vibeWidgetState.collapsed && _vibeWidgetState.unreadCount === 0);
-
- if (shouldShowBadge) {
- statusEl.textContent = `${_vibeWidgetState.unreadCount} new`;
- } else if (_vibeWidgetState.collapsed) {
- statusEl.textContent = '';
- } else {
- let statusText = 'Live';
- if (settings.paused) statusText = 'Paused';
- else if (_vibeWidgetState.syncState === 'retrying') statusText = 'Reconnecting';
- statusEl.textContent = statusText;
- statusEl.classList.remove('is-badge');
- statusEl.classList.remove('is-hidden');
- }
- }
- const toggleEl = panel.querySelector('.chat-vibe-widget-toggle');
- if (toggleEl) {
- toggleEl.textContent = _vibeWidgetState.collapsed ? 'Show' : 'Hide';
- toggleEl.setAttribute('aria-expanded', _vibeWidgetState.collapsed ? 'false' : 'true');
- toggleEl.setAttribute('aria-label', _vibeWidgetState.collapsed ? 'Expand vibe coding widget' : 'Collapse vibe coding widget');
- }
- if (_vibeWidgetState.collapsed) {
- _hideVibeWidgetNewMessagesIndicator();
- }
-
- const maxEvents = settings.max_events || VIBE_WIDGET_FALLBACK_MAX;
- const previousOffsetFromBottom = _vibeWidgetState.feed.scrollHeight - _vibeWidgetState.feed.scrollTop;
- const shouldStickToBottom = !_vibeWidgetState.userScrolledUp || _isVibeWidgetNearBottom();
- const items = _buildVibeWidgetDisplayItems(_vibeWidgetState.events).slice(-maxEvents);
- if (!items.length) {
- _vibeWidgetState.feed.innerHTML = '<div class="chat-vibe-widget-empty">No coding events yet for this slot.</div>';
- _hideVibeWidgetNewMessagesIndicator();
- return;
- }
-
- const existingChildren = Array.from(_vibeWidgetState.feed.children);
- const existingByKey = new Map(existingChildren.map((child) => [child.dataset.vibeKey, child]));
- const nextChildren = [];
-
- items.forEach((item, index) => {
- const itemKey = _getVibeWidgetItemDomKey(item, index);
- const existing = existingByKey.get(itemKey);
- const itemHtml = item.kind === 'system' ? _renderVibeSystemItem(item) : _renderVibeTurnItem(item);
-
- if (existing) {
- if (existing.innerHTML !== itemHtml) {
- const replacement = _createVibeWidgetItemNode(item, itemKey);
- if (replacement) {
- _vibeWidgetState.feed.replaceChild(replacement, existing);
- nextChildren.push(replacement);
- }
- } else {
- nextChildren.push(existing);
- }
- return;
- }
-
- const node = _createVibeWidgetItemNode(item, itemKey);
- if (!node) return;
- const insertBefore = _vibeWidgetState.feed.children[index] || null;
- _vibeWidgetState.feed.insertBefore(node, insertBefore);
- nextChildren.push(node);
- });
-
- const nextKeys = new Set(nextChildren.map((child) => child.dataset.vibeKey));
- existingChildren.forEach((child) => {
- if (!nextKeys.has(child.dataset.vibeKey)) {
- child.remove();
- }
- });
-
- requestAnimationFrame(() => {
- if (!_vibeWidgetState.feed) return;
- if (shouldStickToBottom) {
- _scrollVibeWidgetToBottom();
- return;
- }
- _vibeWidgetState.feed.scrollTop = Math.max(0, _vibeWidgetState.feed.scrollHeight - previousOffsetFromBottom);
- _showVibeWidgetNewMessagesIndicator();
- });
-}
-
-function _pushVibeWidgetEvent(event) {
- if (!event) return;
- if (event.publisher) {
- _vibeWidgetState.publisher = event.publisher;
- }
- const wasNearBottom = _isVibeWidgetNearBottom();
- const addedCount = _mergeVibeWidgetEvents([event]);
- if (!addedCount) {
- return;
- }
- _vibeWidgetState.lastSyncedAt = Date.now();
- _vibeWidgetState.syncState = 'live';
- if (!wasNearBottom) {
- _vibeWidgetState.userScrolledUp = true;
- _vibeWidgetState.unreadCount += addedCount;
- } else {
- _vibeWidgetState.userScrolledUp = false;
- _vibeWidgetState.unreadCount = 0;
- }
- _renderVibeWidget();
-}
-
-function _handleVibeSocketMessage(msg) {
- if (!msg?.event || !_vibeWidgetState.settings?.enabled) return;
- const delayMs = Math.max(0, parseInt(msg.delay_ms, 10) || parseInt(_vibeWidgetState.settings.delay_ms, 10) || 0);
- if (delayMs > 0) {
- const timerId = setTimeout(() => {
- _vibeWidgetState.timers = _vibeWidgetState.timers.filter((id) => id !== timerId);
- _pushVibeWidgetEvent(msg.event);
- }, delayMs);
- _vibeWidgetState.timers.push(timerId);
- return;
- }
- _pushVibeWidgetEvent(msg.event);
-}
-
-async function refreshVibeWidgetFeed() {
- const context = _getVibeWidgetContext();
- if (!context) {
- _resetVibeWidget(true);
- return;
- }
-
- const widgetKey = `${context.username}:${context.slotRef}`;
- if (_vibeWidgetState.key && _vibeWidgetState.key !== widgetKey) {
- _resetVibeWidget(true);
- }
-
- const previousKeys = new Set(_vibeWidgetState.events.map((event) => _getVibeWidgetEventKey(event)));
- const wasNearBottom = _isVibeWidgetNearBottom();
- try {
- const data = await api(`/vibe-coding/channel/${encodeURIComponent(context.username)}/${encodeURIComponent(String(context.slotRef))}/events?limit=${_getVibeWidgetFetchLimit()}`);
- _vibeWidgetState.key = widgetKey;
- _vibeWidgetState.settings = data.settings || null;
- _vibeWidgetState.publisher = data.publisher || null;
- _replaceVibeWidgetEvents(Array.isArray(data.events) ? data.events : []);
- const addedCount = _vibeWidgetState.events.reduce((count, event) => {
- return count + (previousKeys.has(_getVibeWidgetEventKey(event)) ? 0 : 1);
- }, 0);
- _vibeWidgetState.lastSyncedAt = Date.now();
- _vibeWidgetState.syncState = 'live';
- if (!wasNearBottom && addedCount > 0) {
- _vibeWidgetState.userScrolledUp = true;
- _vibeWidgetState.unreadCount += addedCount;
- } else if (wasNearBottom) {
- _vibeWidgetState.userScrolledUp = false;
- _vibeWidgetState.unreadCount = 0;
- }
- _ensureVibeWidgetRefreshTimer();
- _renderVibeWidget();
- } catch {
- _vibeWidgetState.syncState = _vibeWidgetState.settings?.enabled ? 'retrying' : 'idle';
- if (_vibeWidgetState.panel && _vibeWidgetState.settings?.enabled) {
- _renderVibeWidget();
- }
- }
-}
+ const res = await fetch(`${FEED_ORIGIN}/api/chat/feeds?slot=${encodeURIComponent(key)}`, { credentials: 'omit' });
+ feed = res.ok ? ((await res.json()).feeds || [])[0] || null : null;
+ } catch { feed = null; }
+ if (seq !== _feedCard.seq || !feed) return;
+ const { messages } = getChatEl();
+ const host = messages && (messages.closest('#chat-sidebar, .chat-sidebar, .bc-chat-sidebar, .chat-panel, .bc-ws-panel, .bc-chat-panel') || messages.parentElement);
+ if (!host) return;
+ const frame = document.createElement('iframe');
+ frame.className = 'chat-feed-card';
+ frame.title = feed.title || 'Vibe coding, live';
+ frame.src = `${FEED_ORIGIN}/embed/feed/${encodeURIComponent(feed.id)}?theme=${_feedCardTheme()}`;
+ const header = host.querySelector('.chat-header, .global-chat-header');
+ if (header) header.after(frame); else host.prepend(frame);
+ _feedCard.frame = frame;
+}
+
+window.addEventListener('message', (e) => {
+ if (e.origin !== FEED_ORIGIN || !_feedCard.frame || e.source !== _feedCard.frame.contentWindow) return;
+ if (!e.data || e.data.ov !== 'chat' || e.data.type !== 'feed-height') return;
+ _feedCard.frame.style.height = `${Math.min(340, Math.max(42, Number(e.data.height) || 0))}px`;
+});
function getFullscreenChatEls() {
function getFullscreenChatEls() {
return {
return {
@@ -2628,7 +1546,7 @@ function initChat(streamId, channelUserId = null) {
@@ -2628,7 +1546,7 @@ function initChat(streamId, channelUserId = null) {
prevInput.value = '';
prevInput.value = '';
try { _autoResizeTextarea(nextInput); } catch { /* */ }
try { _autoResizeTextarea(nextInput); } catch { /* */ }
}
}
- refreshVibeWidgetFeed().catch(() => { });
+ refreshFeedCard().catch(() => { });
applyChatSettings();
applyChatSettings();
scrollChatToBottom();
scrollChatToBottom();
return;
return;
@@ -2644,7 +1562,7 @@ function initChat(streamId, channelUserId = null) {
@@ -2644,7 +1562,7 @@ function initChat(streamId, channelUserId = null) {
if (targetChanged || needsHydrate) {
if (targetChanged || needsHydrate) {
hydrateActiveChatHistory(streamId, { clear: true }).catch(() => { });
hydrateActiveChatHistory(streamId, { clear: true }).catch(() => { });
}
}
- refreshVibeWidgetFeed().catch(() => { });
+ refreshFeedCard().catch(() => { });
applyChatSettings();
applyChatSettings();
return;
return;
}
}
@@ -2652,7 +1570,7 @@ function initChat(streamId, channelUserId = null) {
@@ -2652,7 +1570,7 @@ function initChat(streamId, channelUserId = null) {
// Reclaim background broadcast WS if it's connected to the same stream
// Reclaim background broadcast WS if it's connected to the same stream
if (_bgBroadcastWs && _bgBroadcastWs.readyState === WebSocket.OPEN && _bgBroadcastStreamId === streamId) {
if (_bgBroadcastWs && _bgBroadcastWs.readyState === WebSocket.OPEN && _bgBroadcastStreamId === streamId) {
- refreshVibeWidgetFeed().catch(() => { });
+ refreshFeedCard().catch(() => { });
destroyChat(); // close any existing foreground chat
destroyChat(); // close any existing foreground chat
chatChannelUserId = channelUserId || null; // destroyChat() clears the old page's channel; this is the new page's
chatChannelUserId = channelUserId || null; // destroyChat() clears the old page's channel; this is the new page's
_syncOwnTtsLeadership();
_syncOwnTtsLeadership();
@@ -2692,7 +1610,7 @@ function initChat(streamId, channelUserId = null) {
@@ -2692,7 +1610,7 @@ function initChat(streamId, channelUserId = null) {
_syncOwnTtsLeadership();
_syncOwnTtsLeadership();
chatStreamId = streamId;
chatStreamId = streamId;
chatRenderTargetId = nextTargetId;
chatRenderTargetId = nextTargetId;
- refreshVibeWidgetFeed().catch(() => { });
+ refreshFeedCard().catch(() => { });
const host = window.location.hostname;
const host = window.location.hostname;
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
@@ -2918,7 +1836,7 @@ function _reconnectChatWs(streamId) {
@@ -2918,7 +1836,7 @@ function _reconnectChatWs(streamId) {
}
}
function destroyChat(forceClose = false) {
function destroyChat(forceClose = false) {
- _resetVibeWidget(true);
+ _removeFeedCard();
_chatIntentionalClose = true;
_chatIntentionalClose = true;
_chatActive = false;
_chatActive = false;
_closeGlobalFeed();
_closeGlobalFeed();
@@ -3184,9 +2102,6 @@ function _applyChatVipBadge(evt) {
@@ -3184,9 +2102,6 @@ function _applyChatVipBadge(evt) {
function handleChatMessage(msg) {
function handleChatMessage(msg) {
switch (msg.type) {
switch (msg.type) {
- case 'vibe-coding':
- _handleVibeSocketMessage(msg);
- break;
case 'chat_translation':
case 'chat_translation':
_applyChatTranslation(msg);
_applyChatTranslation(msg);
break;
break;
@@ -875,12 +875,6 @@ const DASH_TOKEN_SCOPE_FALLBACKS = Object.freeze([
@@ -875,12 +875,6 @@ const DASH_TOKEN_SCOPE_FALLBACKS = Object.freeze([
title: 'Stream control',
title: 'Stream control',
description: 'Start or stop streams and update stream state or metadata.',
description: 'Start or stop streams and update stream state or metadata.',
},
},
- {
- id: 'vibe_coding_publish',
- label: 'vibe_coding_publish',
- title: 'Vibe coding publisher',
- description: 'Publish sanitized coding-feed events to /ws/vibe-coding/publish for a managed stream slot.',
- },
]);
]);
const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([
const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([
{
{
@@ -890,13 +884,6 @@ const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([
@@ -890,13 +884,6 @@ const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([
suggested_label: 'Chat Bot',
suggested_label: 'Chat Bot',
scopes: ['chat', 'read'],
scopes: ['chat', 'read'],
},
},
- {
- id: 'vibe-coding-publisher',
- label: 'GitHub Copilot Companion',
- description: 'Recommended for the OpenVibe.Live VS Code companion and other coding-feed publishers.',
- suggested_label: 'Copilot Companion',
- scopes: ['read', 'vibe_coding_publish'],
- },
{
{
id: 'stream-controller',
id: 'stream-controller',
label: 'Stream Controller',
label: 'Stream Controller',
@@ -1077,7 +1064,7 @@ function showCreateTokenModal() {
@@ -1077,7 +1064,7 @@ function showCreateTokenModal() {
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;flex-direction:column;gap:6px">
${renderDashTokenScopeOptions(['chat', 'read'])}
${renderDashTokenScopeOptions(['chat', 'read'])}
</div>
</div>
- <p class="muted" style="font-size:0.78rem;margin-top:8px">Use <strong>vibe_coding_publish</strong> for coding-feed publishers such as the OpenVibe.Live VS Code companion.</p>
+ <p class="muted" style="font-size:0.78rem;margin-top:8px">Showing your coding session live? Feeds have their own key on <a href="https://openvibe.chat/feeds" target="_blank" rel="noopener">openvibe.chat/feeds</a>.</p>
</div>
</div>
<div class="form-group">
<div class="form-group">
<label>Expires</label>
<label>Expires</label>
@@ -214,7 +214,6 @@ const TOKEN_WRITE_SCOPES = [
@@ -214,7 +214,6 @@ const TOKEN_WRITE_SCOPES = [
['/api/chat', ['chat']], ['/api/dm', ['chat']], ['/api/emotes', ['chat']], ['/api/sounds', ['chat']], ['/api/tts', ['chat']],
['/api/chat', ['chat']], ['/api/dm', ['chat']], ['/api/emotes', ['chat']], ['/api/sounds', ['chat']], ['/api/tts', ['chat']],
['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],
['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],
['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],
['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],
- ['/api/vibe-coding', ['vibe_coding_publish']], // not the broad `stream` scope
];
];
function apiTokenAllows(req, scopes) {
function apiTokenAllows(req, scopes) {
const path = String(req.originalUrl || req.url || '').split('?')[0];
const path = String(req.originalUrl || req.url || '').split('?')[0];
@@ -704,12 +704,6 @@ const TOKEN_SCOPE_DEFINITIONS = Object.freeze([
@@ -704,12 +704,6 @@ const TOKEN_SCOPE_DEFINITIONS = Object.freeze([
title: 'Stream control',
title: 'Stream control',
description: 'Start or stop streams and update stream state or metadata.',
description: 'Start or stop streams and update stream state or metadata.',
},
},
- {
- id: 'vibe_coding_publish',
- label: 'vibe_coding_publish',
- title: 'Vibe coding publisher',
- description: 'Publish sanitized coding-feed events to /ws/vibe-coding/publish for a managed stream slot.',
- },
]);
]);
const TOKEN_PRESET_DEFINITIONS = Object.freeze([
const TOKEN_PRESET_DEFINITIONS = Object.freeze([
@@ -720,13 +714,6 @@ const TOKEN_PRESET_DEFINITIONS = Object.freeze([
@@ -720,13 +714,6 @@ const TOKEN_PRESET_DEFINITIONS = Object.freeze([
suggested_label: 'Chat Bot',
suggested_label: 'Chat Bot',
scopes: ['chat', 'read'],
scopes: ['chat', 'read'],
},
},
- {
- id: 'vibe-coding-publisher',
- label: 'GitHub Copilot Companion',
- description: 'Recommended for the OpenVibe.Live VS Code companion and other coding-feed publishers.',
- suggested_label: 'Copilot Companion',
- scopes: ['read', 'vibe_coding_publish'],
- },
{
{
id: 'stream-controller',
id: 'stream-controller',
label: 'Stream Controller',
label: 'Stream Controller',
@@ -109,7 +109,6 @@ const rtmpServer = require('./streaming/rtmp-server');
@@ -109,7 +109,6 @@ const rtmpServer = require('./streaming/rtmp-server');
// Real-time
// Real-time
const chatDelivery = require('./chat/chat-delivery');
const chatDelivery = require('./chat/chat-delivery');
const broadcastServer = require('./streaming/broadcast-server');
const broadcastServer = require('./streaming/broadcast-server');
-const VibeCodingPublishServer = require('./vibe-coding/publish-server');
// Routes
// Routes
const authRoutes = require('./auth/routes');
const authRoutes = require('./auth/routes');
@@ -136,7 +135,6 @@ const themeRoutes = require('./themes/routes');
@@ -136,7 +135,6 @@ const themeRoutes = require('./themes/routes');
const metaRoutes = require('./meta/routes');
const metaRoutes = require('./meta/routes');
const robotStreamerService = require('./integrations/robotstreamer-service');
const robotStreamerService = require('./integrations/robotstreamer-service');
const chatRelayService = require('./integrations/chat-relay-service');
const chatRelayService = require('./integrations/chat-relay-service');
-const vibeCodingRoutes = require('./vibe-coding/routes');
// Restream
// Restream
const restreamRoutes = require('./streaming/restream-routes');
const restreamRoutes = require('./streaming/restream-routes');
@@ -247,7 +245,6 @@ const observability = require('./web/observability');
@@ -247,7 +245,6 @@ const observability = require('./web/observability');
const { registry: metricsRegistry } = observability.mountMetrics(app, { release });
const { registry: metricsRegistry } = observability.mountMetrics(app, { release });
app.use(telemetry.middleware); // request telemetry (openvibe-sdk/service): a no-op until start() calls telemetry.init()
app.use(telemetry.middleware); // request telemetry (openvibe-sdk/service): a no-op until start() calls telemetry.init()
if (!drill.enabled) require('./ai/ai-media-analyze').registerMetrics(metricsRegistry);
if (!drill.enabled) require('./ai/ai-media-analyze').registerMetrics(metricsRegistry);
-const vibeCodingPublishServer = new VibeCodingPublishServer(db);
function normalizeOrigin(origin) {
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
if (!origin || typeof origin !== 'string') return null;
@@ -380,7 +377,8 @@ app.use(helmet({
@@ -380,7 +377,8 @@ app.use(helmet({
// request tab was doing for every YouTube link.
// request tab was doing for every YouTube link.
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com", "https://openvibe.community"], // paste screenshots (OpenVibe.Community owns pastes)
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com", "https://openvibe.community"], // paste screenshots (OpenVibe.Community owns pastes)
// https://cloudflareinsights.com is where Cloudflare Web Analytics' beacon (script-src static.cloudflareinsights.com) reports.
// https://cloudflareinsights.com is where Cloudflare Web Analytics' beacon (script-src static.cloudflareinsights.com) reports.
- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.events", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", "https://cloudflareinsights.com", whipConnectSrc],
+ // https://openvibe.chat: the channel chat's feed card asks Chat which feed a slot shows (public/js/chat.js).
+ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.chat", "https://openvibe.events", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", "https://cloudflareinsights.com", whipConnectSrc],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs or, for a B2 copy, to its
// itself redirect to presigned B2/R2 object-store URLs or, for a B2 copy, to its
// origin shield edge.openvibe.media — all must be allowed or the browser blocks
// origin shield edge.openvibe.media — all must be allowed or the browser blocks
@@ -388,7 +386,8 @@ app.use(helmet({
@@ -388,7 +386,8 @@ app.use(helmet({
// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
// rejects the element ("no supported source") even though the backend returned audio.
// rejects the element ("no supported source") even though the backend returned audio.
mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://edge.openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://edge.openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
- frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],
+ // https://openvibe.chat: the feed card above a channel's chat is Chat's embed (/embed/feed/:id).
+ frameSrc: ["'self'", "https://openvibe.network", "https://openvibe.chat", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],
workerSrc: ["'self'", "blob:"],
workerSrc: ["'self'", "blob:"],
scriptSrcAttr: ["'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
},
},
@@ -819,7 +818,11 @@ app.use('/api/media', require('./media/routes'));
@@ -819,7 +818,11 @@ app.use('/api/media', require('./media/routes'));
// Privacy proxy for third-party images (offline-screen HTML, panels): fetched server-side so a
// Privacy proxy for third-party images (offline-screen HTML, panels): fetched server-side so a
// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
app.use('/api/img-proxy', require('./media/external-image-proxy'));
app.use('/api/img-proxy', require('./media/external-image-proxy'));
-app.use('/api/vibe-coding', vibeCodingRoutes);
+// Vibe coding feeds moved to OpenVibe.Chat (2026-10-24, its server/feeds/): the old routes say where they went. A page
+// of the previous release still asks for a slot's feed; it gets one that is off, so its widget stays hidden (N-1).
+const VIBE_CODING_OFF = Object.freeze({ enabled: 0, widget_title: 'Vibe Coding', viewer_depth: 'standard', show_prompts: 1, show_responses: 1, show_thinking: 0, show_tool_calls: 1, show_tool_arguments: 0, show_file_events: 1, show_file_snippets: 0, redact_file_paths: 1, paused: 0, delay_ms: 0, max_events: 18, max_prompt_chars: 220, max_response_chars: 360, max_thinking_chars: 140, max_tool_chars: 140, max_snippet_chars: 140 });
+app.get('/api/vibe-coding/channel/:username/:slot/events', (req, res) => res.json({ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF, publisher: null, events: [], moved_to: 'https://openvibe.chat/feeds' }));
+app.use('/api/vibe-coding', (req, res) => res.status(410).json({ error: 'Vibe coding feeds moved to OpenVibe.Chat', moved_to: 'https://openvibe.chat/feeds', api: 'https://openvibe.chat/api/chat/feeds' }));
const analyticsRoutes = require('./streaming/analytics-routes');
const analyticsRoutes = require('./streaming/analytics-routes');
app.use('/api/analytics', analyticsRoutes);
app.use('/api/analytics', analyticsRoutes);
const newsRoutes = require('./news/news-routes');
const newsRoutes = require('./news/news-routes');
@@ -1210,9 +1213,7 @@ server.on('upgrade', async (req, socket, head) => {
@@ -1210,9 +1213,7 @@ server.on('upgrade', async (req, socket, head) => {
} catch (e) { /* non-critical — allow through on DB error */ }
} catch (e) { /* non-critical — allow through on DB error */ }
// /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.
// /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.
- if (url.startsWith('/ws/vibe-coding/publish')) {
- vibeCodingPublishServer.handleUpgrade(req, socket, head);
- } else if (url.startsWith('/ws/broadcast')) {
+ if (url.startsWith('/ws/broadcast')) {
broadcastServer.handleUpgrade(req, socket, head);
broadcastServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/robotstreamer-publish')) {
} else if (url.startsWith('/ws/robotstreamer-publish')) {
robotStreamerService.handleUpgrade(req, socket, head);
robotStreamerService.handleUpgrade(req, socket, head);
@@ -1273,7 +1274,6 @@ async function start() {
@@ -1273,7 +1274,6 @@ async function start() {
// 3. Initialize chat server
// 3. Initialize chat server
await chatDelivery.init();
await chatDelivery.init();
- vibeCodingPublishServer.init(server);
// 3b. Initialize breaking news service
// 3b. Initialize breaking news service
const newsService = require('./news/news-service');
const newsService = require('./news/news-service');
@@ -5,10 +5,15 @@
@@ -5,10 +5,15 @@
* A path Live never served, or no longer serves, gets a real HTTP answer instead of a dropped socket: nginx logged
* A path Live never served, or no longer serves, gets a real HTTP answer instead of a dropped socket: nginx logged
* every drop as a 502. Old Pi robot clients still reconnect to /ws/control every few seconds (robots moved to
* every drop as a 502. Old Pi robot clients still reconnect to /ws/control every few seconds (robots moved to
* OpenVibe.Bot, plan T15): 2,137 502s from two of them in three hours on 2026-10-23. They now get a 410 that says
* OpenVibe.Bot, plan T15): 2,137 502s from two of them in three hours on 2026-10-23. They now get a 410 that says
- * where control went, and neither answer costs a ban lookup.
+ * where control went, and neither answer costs a ban lookup. The vibe coding publisher socket answers the same way
+ * since feeds moved to OpenVibe.Chat.
*/
*/
-const SERVED = ['/ws/vibe-coding/publish', '/ws/broadcast', '/ws/robotstreamer-publish'];
-const GONE = { '/ws/control': 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' };
+const SERVED = ['/ws/broadcast', '/ws/robotstreamer-publish'];
+const GONE = {
+ '/ws/control': 'Robot control moved to OpenVibe.Bot: https://openvibe.bot',
+ // Vibe coding feeds moved to OpenVibe.Chat (2026-10-24): a publisher posts to the feed's API there with its key.
+ '/ws/vibe-coding/publish': 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds',
+};
/** 'served' for a path a WebSocket server here answers, else { status, text } to answer with. */
/** 'served' for a path a WebSocket server here answers, else { status, text } to answer with. */
function classify(url) {
function classify(url) {
deleted file mode 100644
deleted file mode 100644
@@ -1,199 +0,0 @@
@@ -1,199 +0,0 @@
-'use strict';
-
-const WebSocket = require('ws');
-
-const { extractWsToken, authenticateWs } = require('../auth/auth');
-const vibeService = require('./service');
-
-// Only the narrow publisher scope. The broad `stream` scope (live state, restreams, VODs, clips)
-// used to be accepted too, so any stream-control token could publish a coding feed; a publisher
-// token needs vibe_coding_publish (the Dashboard's "GitHub Copilot Companion" preset has it).
-// Moving publication onto Network project credentials (surfaced in OpenVibe.Codes) is later work.
-const VIBE_CODING_PUBLISH_SCOPES = new Set(['vibe_coding_publish']);
-
-function hasVibeCodingPublishScope(user) {
- return !!(user && Array.isArray(user.scopes) && user.scopes.some((scope) => VIBE_CODING_PUBLISH_SCOPES.has(scope)));
-}
-
-class VibeCodingPublishServer {
- constructor(db) {
- this.db = db;
- this.wss = null;
- this.clients = new Map();
- }
-
- init(server) {
- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });
- this.wss.on('connection', (ws, req) => {
- this.handleConnection(ws, req).catch((e) => console.warn('[VibeCoding] connection failed:', e && e.message));
- });
- return this.wss;
- }
-
- handleUpgrade(req, socket, head) {
- if (!req.url || !req.url.startsWith('/ws/vibe-coding/publish')) {
- return false;
- }
- this.wss.handleUpgrade(req, socket, head, (ws) => {
- this.wss.emit('connection', ws, req);
- });
- return true;
- }
-
- async handleConnection(ws, req) {
- const token = extractWsToken(req);
- const user = await authenticateWs(token);
- if (!user) {
- ws.close(4401, 'Authentication required');
- return;
- }
- if (user._authSource === 'api_token' && !hasVibeCodingPublishScope(user)) {
- ws.close(4403, 'API token requires vibe_coding_publish scope');
- return;
- }
-
- const params = new URL(req.url, 'http://localhost').searchParams;
- const managedStreamId = parseInt(params.get('managedStreamId') || '', 10);
- const managedStream = await this.db.getManagedStreamById(managedStreamId);
- if (!Number.isFinite(managedStreamId) || !managedStream || managedStream.user_id !== user.id) {
- ws.close(4404, 'Managed stream not found');
- return;
- }
-
- const client = {
- user,
- managedStreamId,
- slotSlug: params.get('slotSlug') || managedStream.slug || null,
- sessionKey: null,
- };
- this.clients.set(ws, client);
-
- const feed = await vibeService.getProjectedViewerFeed(managedStreamId, 1);
- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managedStreamId);
- this.sendTo(ws, {
- type: 'vibe-coding.ready',
- ok: true,
- managed_stream_id: managedStreamId,
- slot_slug: client.slotSlug,
- live_stream_id: liveStream?.id || null,
- settings: feed.settings,
- timestamp: new Date().toISOString(),
- });
-
- ws.on('message', (data) => {
- this.handleMessage(ws, data).catch((e) => console.warn('[VibeCoding] message failed:', e && e.message));
- });
-
- ws.on('close', async () => {
- if (client.sessionKey) {
- await vibeService.markSessionEnded(client.managedStreamId, client.sessionKey);
- }
- this.clients.delete(ws);
- });
- }
-
- async handleMessage(ws, data) {
- const client = this.clients.get(ws);
- if (!client) return;
-
- let message;
- try {
- message = JSON.parse(data.toString());
- } catch {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'Invalid JSON payload' });
- return;
- }
-
- if (!message || typeof message !== 'object') {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'Invalid payload' });
- return;
- }
-
- switch (message.type) {
- case 'vibe-coding.hello':
- client.sessionKey = String(message.sessionKey || '').trim() || null;
- if (!client.sessionKey) {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'sessionKey is required' });
- return;
- }
- await vibeService.upsertVibeCodingSession({
- managedStreamId: client.managedStreamId,
- userId: client.user.id,
- slotSlug: client.slotSlug,
- helloMessage: message,
- });
- this.sendTo(ws, {
- type: 'vibe-coding.hello.ack',
- session_key: client.sessionKey,
- timestamp: new Date().toISOString(),
- });
- return;
-
- case 'vibe-coding.event':
- if (!message.event || typeof message.event !== 'object') {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'event payload is required' });
- return;
- }
- if (!client.sessionKey) {
- client.sessionKey = String(message.sessionKey || message.event.sessionKey || '').trim() || null;
- }
- if (!client.sessionKey) {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'sessionKey must be established before events are accepted' });
- return;
- }
- await this.processEvent(ws, client, message.event);
- return;
-
- case 'vibe-coding.ping':
- this.sendTo(ws, { type: 'vibe-coding.pong', timestamp: new Date().toISOString() });
- return;
-
- default:
- this.sendTo(ws, { type: 'vibe-coding.error', error: `Unsupported message type: ${message.type}` });
- }
- }
-
- async processEvent(ws, client, event) {
- if (!event.eventId || !event.eventType) {
- this.sendTo(ws, { type: 'vibe-coding.error', error: 'eventId and eventType are required' });
- return;
- }
-
- const liveStream = await vibeService.getLiveStreamByManagedStreamId(client.managedStreamId);
- await vibeService.storeVibeCodingEvent({
- managedStreamId: client.managedStreamId,
- userId: client.user.id,
- streamId: liveStream?.id || null,
- event,
- });
-
- const settings = await vibeService.getManagedStreamVibeCodingSettings(client.managedStreamId);
- const projected = vibeService.projectViewerEvent(event, settings);
- if (projected && liveStream?.id) {
- await require('../chat/chat-delivery').event({ kind: 'stream', id: liveStream.id }, {
- type: 'vibe-coding',
- managed_stream_id: client.managedStreamId,
- slot_slug: client.slotSlug,
- delay_ms: settings.delay_ms,
- event: projected,
- }, { key: event.eventId ? `vibe:${client.managedStreamId}:${event.eventId}` : undefined });
- }
-
- this.sendTo(ws, {
- type: 'vibe-coding.ack',
- event_id: event.eventId,
- live_stream_id: liveStream?.id || null,
- delivered: !!(projected && liveStream?.id),
- timestamp: new Date().toISOString(),
- });
- }
-
- sendTo(ws, payload) {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(payload));
- }
- }
-}
-
-module.exports = VibeCodingPublishServer;
-module.exports.hasVibeCodingPublishScope = hasVibeCodingPublishScope;
\ No newline at end of file
\ No newline at end of file
deleted file mode 100644
deleted file mode 100644
@@ -1,106 +0,0 @@
@@ -1,106 +0,0 @@
-'use strict';
-
-const express = require('express');
-
-const db = require('../db/database');
-const { requireAuth, optionalAuth } = require('../auth/auth');
-const vibeService = require('./service');
-
-const router = express.Router();
-
-function clampLimit(value, fallback = 20) {
- const parsed = parseInt(value, 10);
- if (!Number.isFinite(parsed)) return fallback;
- return Math.min(100, Math.max(1, parsed));
-}
-
-router.get('/channel/:username/:slotIdOrSlug/events', optionalAuth, async (req, res) => {
- try {
- const username = String(req.params.username || '').replace(/^@/, '').trim();
- const channel = await db.getChannelByUsername(username);
- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-
- const managed = await db.getManagedStreamByIdOrSlug(channel.user_id, req.params.slotIdOrSlug);
- if (!managed) return res.status(404).json({ error: 'Managed stream not found' });
-
- const limit = clampLimit(req.query.limit, 18);
- const feed = await vibeService.getProjectedViewerFeed(managed.id, limit);
- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managed.id);
-
- res.json({
- managed_stream: {
- id: managed.id,
- slug: managed.slug,
- title: managed.title,
- },
- live_stream_id: liveStream?.id || null,
- settings: feed.settings,
- publisher: feed.publisher,
- events: feed.events,
- });
- } catch (err) {
- console.error('[VibeCoding] Public feed error:', err.message);
- res.status(500).json({ error: 'Failed to load vibe-coding feed' });
- }
-});
-
-router.get('/managed/:managedStreamId/events', requireAuth, async (req, res) => {
- try {
- const managedStreamId = parseInt(req.params.managedStreamId, 10);
- const managed = await db.getManagedStreamById(managedStreamId);
- if (!managed || managed.user_id !== req.user.id) {
- return res.status(404).json({ error: 'Managed stream not found' });
- }
-
- const limit = clampLimit(req.query.limit, 50);
- const rawEvents = await vibeService.getStoredEventsForManagedStream(managedStreamId, limit);
- const feed = await vibeService.getProjectedViewerFeed(managedStreamId, limit);
- const liveStream = await vibeService.getLiveStreamByManagedStreamId(managedStreamId);
-
- res.json({
- managed_stream_id: managedStreamId,
- live_stream_id: liveStream?.id || null,
- settings: feed.settings,
- publisher: feed.publisher,
- events: rawEvents,
- public_events: feed.events,
- });
- } catch (err) {
- console.error('[VibeCoding] Managed feed error:', err.message);
- res.status(500).json({ error: 'Failed to load vibe-coding events' });
- }
-});
-
-router.get('/managed/:managedStreamId/settings', requireAuth, async (req, res) => {
- try {
- const managedStreamId = parseInt(req.params.managedStreamId, 10);
- const managed = await db.getManagedStreamById(managedStreamId);
- if (!managed || managed.user_id !== req.user.id) {
- return res.status(404).json({ error: 'Managed stream not found' });
- }
- res.json({
- managed_stream_id: managedStreamId,
- settings: await vibeService.getManagedStreamVibeCodingSettings(managedStreamId),
- });
- } catch (err) {
- console.error('[VibeCoding] Settings read error:', err.message);
- res.status(500).json({ error: 'Failed to load vibe-coding settings' });
- }
-});
-
-router.put('/managed/:managedStreamId/settings', requireAuth, async (req, res) => {
- try {
- const managedStreamId = parseInt(req.params.managedStreamId, 10);
- const managed = await db.getManagedStreamById(managedStreamId);
- if (!managed || managed.user_id !== req.user.id) {
- return res.status(404).json({ error: 'Managed stream not found' });
- }
- const settings = await vibeService.updateManagedStreamVibeCodingSettings(managedStreamId, req.user.id, req.body?.settings || {});
- res.json({ managed_stream_id: managedStreamId, settings });
- } catch (err) {
- console.error('[VibeCoding] Settings save error:', err.message);
- res.status(500).json({ error: 'Failed to save vibe-coding settings' });
- }
-});
-
-module.exports = router;
\ No newline at end of file
\ No newline at end of file
deleted file mode 100644
deleted file mode 100644
@@ -1,507 +0,0 @@
@@ -1,507 +0,0 @@
-'use strict';
-
-const path = require('path');
-
-const db = require('../db/database');
-
-const DEFAULT_VIBE_CODING_SETTINGS = Object.freeze({
- enabled: 0,
- widget_title: 'Vibe Coding',
- viewer_depth: 'standard',
- show_prompts: 1,
- show_responses: 1,
- show_thinking: 0,
- show_tool_calls: 1,
- show_tool_arguments: 0,
- show_file_events: 1,
- show_file_snippets: 0,
- redact_file_paths: 1,
- paused: 0,
- delay_ms: 0,
- max_events: 18,
- max_prompt_chars: 220,
- max_response_chars: 360,
- max_thinking_chars: 140,
- max_tool_chars: 140,
- max_snippet_chars: 140,
-});
-
-const DEFAULT_PUBLISHER = Object.freeze({
- integrationId: 'custom',
- integrationLabel: 'Custom Publisher',
- vendor: null,
- clientType: 'other',
- clientName: 'Custom Publisher',
- clientVersion: null,
- workspaceName: null,
- machineName: null,
- viewerDepth: DEFAULT_VIBE_CODING_SETTINGS.viewer_depth,
- capabilities: {
- thinking: false,
- toolCalls: false,
- workspaceFileEvents: false,
- },
-});
-
-const LEGACY_COPILOT_PUBLISHER = Object.freeze({
- integrationId: 'github-copilot',
- integrationLabel: 'GitHub Copilot',
- vendor: 'GitHub',
- clientType: 'vscode-extension',
- clientName: 'OpenVibe.Live Copilot Companion',
-});
-
-function clampInt(value, min, max, fallback) {
- const num = Number(value);
- if (!Number.isFinite(num)) return fallback;
- return Math.min(max, Math.max(min, Math.round(num)));
-}
-
-function normalizeBool(value, fallback) {
- if (value === undefined || value === null) return fallback;
- return value ? 1 : 0;
-}
-
-function normalizeStringOrNull(value) {
- if (value === undefined || value === null) return null;
- const normalized = String(value).trim();
- return normalized || null;
-}
-
-function normalizeDepth(value, fallback = DEFAULT_VIBE_CODING_SETTINGS.viewer_depth) {
- const normalized = String(value || fallback).trim().toLowerCase();
- if (normalized === 'headline' || normalized === 'deep') return normalized;
- return 'standard';
-}
-
-function normalizePublisherClientType(value) {
- const normalized = String(value || '').trim().toLowerCase();
- if (normalized === 'vscode-extension' || normalized === 'cli' || normalized === 'browser' || normalized === 'service') {
- return normalized;
- }
- return 'other';
-}
-
-function parseJson(value, fallback) {
- if (value === undefined || value === null || value === '') return fallback;
- if (typeof value === 'object') return value;
- try {
- return JSON.parse(value);
- } catch {
- return fallback;
- }
-}
-
-function getPublisherFallback(source) {
- if (source && typeof source === 'object' && (source.extensionVersion || source.clientVersion || source.sendsThinking !== undefined || source.sendsToolCalls !== undefined || source.sendsWorkspaceFileEvents !== undefined)) {
- return LEGACY_COPILOT_PUBLISHER;
- }
- return DEFAULT_PUBLISHER;
-}
-
-function normalizePublisherDescriptor(value) {
- const source = value && typeof value === 'object' ? value : {};
- const fallback = getPublisherFallback(source);
- const rawCapabilities = parseJson(source.capabilities, {}) || {};
-
- return {
- integrationId: normalizeStringOrNull(source.integrationId) || fallback.integrationId,
- integrationLabel: normalizeStringOrNull(source.integrationLabel) || normalizeStringOrNull(source.integrationId) || fallback.integrationLabel,
- vendor: normalizeStringOrNull(source.vendor) || fallback.vendor,
- clientType: normalizePublisherClientType(source.clientType || fallback.clientType),
- clientName: normalizeStringOrNull(source.clientName) || normalizeStringOrNull(source.integrationLabel) || fallback.clientName,
- clientVersion: normalizeStringOrNull(source.clientVersion || source.extensionVersion),
- workspaceName: normalizeStringOrNull(source.workspaceName),
- machineName: normalizeStringOrNull(source.machineName),
- viewerDepth: normalizeDepth(source.viewerDepth),
- capabilities: {
- thinking: rawCapabilities.thinking !== undefined ? !!rawCapabilities.thinking : !!source.sendsThinking,
- toolCalls: rawCapabilities.toolCalls !== undefined ? !!rawCapabilities.toolCalls : !!source.sendsToolCalls,
- workspaceFileEvents: rawCapabilities.workspaceFileEvents !== undefined ? !!rawCapabilities.workspaceFileEvents : !!source.sendsWorkspaceFileEvents,
- },
- };
-}
-
-function normalizeEventPublisher(value) {
- if (!value || typeof value !== 'object') return null;
- const publisher = normalizePublisherDescriptor(value);
- return {
- integrationId: publisher.integrationId,
- integrationLabel: publisher.integrationLabel,
- vendor: publisher.vendor,
- clientType: publisher.clientType,
- clientName: publisher.clientName,
- };
-}
-
-function normalizeVibeCodingSettings(value) {
- const source = value && typeof value === 'object' ? value : {};
- return {
- enabled: normalizeBool(source.enabled, DEFAULT_VIBE_CODING_SETTINGS.enabled),
- widget_title: String(source.widget_title || DEFAULT_VIBE_CODING_SETTINGS.widget_title).trim() || DEFAULT_VIBE_CODING_SETTINGS.widget_title,
- viewer_depth: normalizeDepth(source.viewer_depth),
- show_prompts: normalizeBool(source.show_prompts, DEFAULT_VIBE_CODING_SETTINGS.show_prompts),
- show_responses: normalizeBool(source.show_responses, DEFAULT_VIBE_CODING_SETTINGS.show_responses),
- show_thinking: normalizeBool(source.show_thinking, DEFAULT_VIBE_CODING_SETTINGS.show_thinking),
- show_tool_calls: normalizeBool(source.show_tool_calls, DEFAULT_VIBE_CODING_SETTINGS.show_tool_calls),
- show_tool_arguments: normalizeBool(source.show_tool_arguments, DEFAULT_VIBE_CODING_SETTINGS.show_tool_arguments),
- show_file_events: normalizeBool(source.show_file_events, DEFAULT_VIBE_CODING_SETTINGS.show_file_events),
- show_file_snippets: normalizeBool(source.show_file_snippets, DEFAULT_VIBE_CODING_SETTINGS.show_file_snippets),
- redact_file_paths: normalizeBool(source.redact_file_paths, DEFAULT_VIBE_CODING_SETTINGS.redact_file_paths),
- paused: normalizeBool(source.paused, DEFAULT_VIBE_CODING_SETTINGS.paused),
- delay_ms: clampInt(source.delay_ms, 0, 120000, DEFAULT_VIBE_CODING_SETTINGS.delay_ms),
- max_events: clampInt(source.max_events, 1, 100, DEFAULT_VIBE_CODING_SETTINGS.max_events),
- max_prompt_chars: clampInt(source.max_prompt_chars, 60, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_prompt_chars),
- max_response_chars: clampInt(source.max_response_chars, 80, 2400, DEFAULT_VIBE_CODING_SETTINGS.max_response_chars),
- max_thinking_chars: clampInt(source.max_thinking_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_thinking_chars),
- max_tool_chars: clampInt(source.max_tool_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_tool_chars),
- max_snippet_chars: clampInt(source.max_snippet_chars, 40, 1200, DEFAULT_VIBE_CODING_SETTINGS.max_snippet_chars),
- };
-}
-
-function parseBroadcastSettings(json) {
- if (!json) return {};
- if (typeof json === 'object') return json;
- try {
- return JSON.parse(json);
- } catch {
- return {};
- }
-}
-
-function getStoredVibeCodingSettings(broadcastSettings) {
- if (!broadcastSettings || typeof broadcastSettings !== 'object') return null;
- if (broadcastSettings.vibe_coding && typeof broadcastSettings.vibe_coding === 'object') {
- return broadcastSettings.vibe_coding;
- }
- if (broadcastSettings.vibeCoding && typeof broadcastSettings.vibeCoding === 'object') {
- return broadcastSettings.vibeCoding;
- }
- return null;
-}
-
-function setStoredVibeCodingSettings(broadcastSettings, settings) {
- if (!broadcastSettings || typeof broadcastSettings !== 'object') return;
- broadcastSettings.vibe_coding = settings;
- broadcastSettings.vibeCoding = settings;
-}
-
-async function getManagedStreamRow(managedStreamId) {
- return await db.get(
- `SELECT ms.*, u.username, u.display_name, u.avatar_url, u.profile_color
- FROM managed_streams ms
- JOIN users u ON ms.user_id = u.id
- WHERE ms.id = ?`,
- [managedStreamId]
- );
-}
-
-async function getManagedStreamVibeCodingSettings(managedStreamId) {
- const row = await getManagedStreamRow(managedStreamId);
- if (!row) return normalizeVibeCodingSettings();
- const broadcastSettings = parseBroadcastSettings(row.broadcast_settings);
- return normalizeVibeCodingSettings(getStoredVibeCodingSettings(broadcastSettings));
-}
-
-async function updateManagedStreamVibeCodingSettings(managedStreamId, userId, partialSettings) {
- const row = await db.get('SELECT broadcast_settings FROM managed_streams WHERE id = ? AND user_id = ?', [managedStreamId, userId]);
- if (!row) return null;
- const broadcastSettings = parseBroadcastSettings(row.broadcast_settings);
- const nextSettings = normalizeVibeCodingSettings({
- ...getStoredVibeCodingSettings(broadcastSettings),
- ...(partialSettings || {}),
- });
- setStoredVibeCodingSettings(broadcastSettings, nextSettings);
- await db.updateManagedStreamBroadcastSettings(managedStreamId, userId, broadcastSettings);
- return nextSettings;
-}
-
-async function getLiveStreamByManagedStreamId(managedStreamId) {
- return await db.get(
- `SELECT s.*, ms.slug AS managed_stream_slug
- FROM streams s
- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
- WHERE s.managed_stream_id = ? AND s.is_live = 1
- ORDER BY COALESCE(s.started_at, s.created_at) DESC
- LIMIT 1`,
- [managedStreamId]
- );
-}
-
-async function upsertVibeCodingSession({ managedStreamId, userId, slotSlug, helloMessage }) {
- const publisher = normalizePublisherDescriptor(helloMessage?.publisher);
- await db.run(
- `INSERT INTO vibe_coding_sessions (
- managed_stream_id, user_id, session_key, slot_slug, workspace_name,
- machine_name, extension_version, publisher_id, publisher_label,
- publisher_vendor, publisher_client_type, publisher_client_name,
- publisher_client_version, publisher_capabilities_json,
- publisher_depth, status, last_event_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ov_now())
- ON CONFLICT(managed_stream_id, session_key) DO UPDATE SET
- workspace_name = excluded.workspace_name,
- machine_name = excluded.machine_name,
- extension_version = excluded.extension_version,
- publisher_id = excluded.publisher_id,
- publisher_label = excluded.publisher_label,
- publisher_vendor = excluded.publisher_vendor,
- publisher_client_type = excluded.publisher_client_type,
- publisher_client_name = excluded.publisher_client_name,
- publisher_client_version = excluded.publisher_client_version,
- publisher_capabilities_json = excluded.publisher_capabilities_json,
- publisher_depth = excluded.publisher_depth,
- slot_slug = excluded.slot_slug,
- status = 'active',
- ended_at = NULL,
- last_event_at = ov_now()`,
- [
- managedStreamId,
- userId,
- String(helloMessage.sessionKey || ''),
- slotSlug || null,
- publisher.workspaceName,
- publisher.machineName,
- publisher.clientVersion,
- publisher.integrationId,
- publisher.integrationLabel,
- publisher.vendor,
- publisher.clientType,
- publisher.clientName,
- publisher.clientVersion,
- JSON.stringify(publisher.capabilities),
- publisher.viewerDepth,
- ]
- );
-}
-
-async function getLatestPublisherForManagedStream(managedStreamId) {
- const row = await db.get(
- `SELECT workspace_name, machine_name, extension_version, publisher_id,
- publisher_label, publisher_vendor, publisher_client_type,
- publisher_client_name, publisher_client_version,
- publisher_capabilities_json, publisher_depth
- FROM vibe_coding_sessions
- WHERE managed_stream_id = ?
- ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC
- LIMIT 1`,
- [managedStreamId]
- );
- if (!row) return null;
- return normalizePublisherDescriptor({
- integrationId: row.publisher_id,
- integrationLabel: row.publisher_label,
- vendor: row.publisher_vendor,
- clientType: row.publisher_client_type,
- clientName: row.publisher_client_name,
- clientVersion: row.publisher_client_version || row.extension_version,
- workspaceName: row.workspace_name,
- machineName: row.machine_name,
- viewerDepth: row.publisher_depth,
- capabilities: parseJson(row.publisher_capabilities_json, {}),
- });
-}
-
-async function markSessionEnded(managedStreamId, sessionKey) {
- await db.run(
- `UPDATE vibe_coding_sessions
- SET status = 'ended', ended_at = ov_now(), last_event_at = ov_now()
- WHERE managed_stream_id = ? AND session_key = ?`,
- [managedStreamId, sessionKey]
- );
-}
-
-async function storeVibeCodingEvent({ managedStreamId, userId, streamId, event }) {
- await db.run(
- `INSERT INTO vibe_coding_events (
- managed_stream_id, user_id, stream_id, session_key, event_id,
- sequence_num, event_type, visibility, depth, summary, payload_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(managed_stream_id, event_id) DO UPDATE SET
- stream_id = excluded.stream_id,
- sequence_num = excluded.sequence_num,
- event_type = excluded.event_type,
- visibility = excluded.visibility,
- depth = excluded.depth,
- summary = excluded.summary,
- payload_json = excluded.payload_json,
- created_at = ov_now()`,
- [
- managedStreamId,
- userId,
- streamId || null,
- event.sessionKey || null,
- event.eventId,
- Number(event.sequence || 0),
- String(event.eventType || 'session.status'),
- String(event.visibility || 'public'),
- String(event.depth || 'standard'),
- String(event.summary || ''),
- JSON.stringify(event),
- ]
- );
- await db.run(
- `UPDATE vibe_coding_sessions
- SET last_event_at = ov_now()
- WHERE managed_stream_id = ? AND session_key = ?`,
- [managedStreamId, event.sessionKey || null]
- );
-}
-
-async function getStoredEventsForManagedStream(managedStreamId, limit = DEFAULT_VIBE_CODING_SETTINGS.max_events) {
- const rows = await db.all(
- `SELECT payload_json, created_at
- FROM vibe_coding_events
- WHERE managed_stream_id = ?
- ORDER BY id DESC
- LIMIT ?`,
- [managedStreamId, clampInt(limit, 1, 100, DEFAULT_VIBE_CODING_SETTINGS.max_events)]
- );
-
- return rows.reverse().map((row) => {
- try {
- return JSON.parse(row.payload_json);
- } catch {
- return null;
- }
- }).filter(Boolean);
-}
-
-function truncateText(value, maxChars, depth) {
- const text = String(value || '').replace(/\s+/g, ' ').trim();
- if (!text) return '';
- let limit = maxChars;
- if (depth === 'headline') {
- limit = Math.max(40, Math.floor(maxChars * 0.6));
- } else if (depth === 'deep') {
- limit = Math.max(maxChars, Math.floor(maxChars * 1.1));
- }
- if (text.length <= limit) return text;
- return `${text.slice(0, Math.max(20, limit - 1)).trim()}…`;
-}
-
-function shouldExposePublicToolPreview(toolName, settings) {
- if (settings?.show_tool_arguments) return true;
- const normalized = String(toolName || '').trim().toLowerCase();
- return normalized === 'run_in_terminal'
- || normalized === 'create_and_run_task'
- || normalized === 'run_notebook_cell';
-}
-
-function projectViewerEvent(event, settings) {
- if (!event || !settings || !settings.enabled || settings.paused) {
- return null;
- }
-
- const projected = {
- eventId: event.eventId,
- eventType: event.eventType,
- occurredAt: event.occurredAt,
- sequence: event.sequence,
- depth: settings.viewer_depth,
- summary: '',
- source: event.source || null,
- prompt: null,
- response: null,
- thinking: null,
- tool: null,
- file: null,
- publisher: normalizeEventPublisher(event?.publisher),
- session: event.session || null,
- metadata: {
- requestId: event?.metadata?.requestId || null,
- responseId: event?.metadata?.responseId || null,
- workspaceName: event?.metadata?.workspaceName || null,
- model: event?.metadata?.model || null,
- },
- };
-
- if (event.eventType === 'session.status') {
- projected.summary = truncateText(event.summary, settings.max_prompt_chars, settings.viewer_depth);
- return projected;
- }
-
- if (event.eventType === 'prompt') {
- if (!settings.show_prompts) return null;
- const text = truncateText(event?.prompt?.text || event.summary, settings.max_prompt_chars, settings.viewer_depth);
- projected.summary = text;
- projected.prompt = { text };
- return projected;
- }
-
- if (event.eventType === 'response') {
- if (!settings.show_responses) return null;
- const text = truncateText(event?.response?.text || event.summary, settings.max_response_chars, settings.viewer_depth);
- projected.summary = text;
- projected.response = { text };
- return projected;
- }
-
- if (event.eventType === 'thinking') {
- if (!settings.show_thinking) return null;
- const text = truncateText(event?.thinking?.text || event.summary, settings.max_thinking_chars, settings.viewer_depth);
- projected.summary = text;
- projected.thinking = { text };
- return projected;
- }
-
- if (event.eventType === 'tool.call') {
- if (!settings.show_tool_calls) return null;
- const includeToolPreview = shouldExposePublicToolPreview(event?.tool?.name, settings);
- projected.summary = truncateText(event.summary || `Used ${event?.tool?.name || 'tool'}`, settings.max_tool_chars, settings.viewer_depth);
- projected.tool = {
- name: event?.tool?.name || 'tool',
- phase: event?.tool?.phase || 'completed',
- argumentsPreview: includeToolPreview ? truncateText(event?.tool?.argumentsPreview || '', settings.max_tool_chars, settings.viewer_depth) : null,
- resultPreview: includeToolPreview ? truncateText(event?.tool?.resultPreview || '', settings.max_tool_chars, settings.viewer_depth) : null,
- };
- return projected;
- }
-
- if (event.eventType === 'file.change' || event.eventType === 'file.save') {
- if (!settings.show_file_events) return null;
- const rawPath = String(event?.file?.relativePath || event?.file?.name || '').trim();
- const displayPath = settings.redact_file_paths ? path.posix.basename(rawPath.replace(/\\/g, '/')) : rawPath;
- projected.summary = event.eventType === 'file.save'
- ? `Saved ${displayPath || event?.file?.name || 'file'}`
- : `Editing ${displayPath || event?.file?.name || 'file'}`;
- projected.file = {
- name: event?.file?.name || displayPath || 'file',
- relativePath: displayPath || null,
- operation: event?.file?.operation || (event.eventType === 'file.save' ? 'save' : 'edit'),
- changeCount: Number(event?.file?.changeCount || 0),
- snippet: settings.show_file_snippets ? truncateText(event?.file?.snippet || '', settings.max_snippet_chars, settings.viewer_depth) : null,
- };
- return projected;
- }
-
- return null;
-}
-
-async function getProjectedViewerFeed(managedStreamId, limit) {
- const settings = await getManagedStreamVibeCodingSettings(managedStreamId);
- const events = (await getStoredEventsForManagedStream(managedStreamId, limit || settings.max_events))
- .map((event) => projectViewerEvent(event, settings))
- .filter(Boolean);
- return {
- settings,
- publisher: await getLatestPublisherForManagedStream(managedStreamId),
- events,
- };
-}
-
-module.exports = {
- DEFAULT_VIBE_CODING_SETTINGS,
- normalizeVibeCodingSettings,
- normalizePublisherDescriptor,
- getManagedStreamRow,
- getManagedStreamVibeCodingSettings,
- updateManagedStreamVibeCodingSettings,
- getLiveStreamByManagedStreamId,
- getLatestPublisherForManagedStream,
- upsertVibeCodingSession,
- markSessionEnded,
- storeVibeCodingEvent,
- getStoredEventsForManagedStream,
- projectViewerEvent,
- getProjectedViewerFeed,
-};
\ No newline at end of file
\ No newline at end of file
@@ -44,7 +44,7 @@ function load({ streaming = false } = {}) {
@@ -44,7 +44,7 @@ function load({ streaming = false } = {}) {
isStreaming: () => streaming,
isStreaming: () => streaming,
getChatRenderTargetId: () => 'chat-messages',
getChatRenderTargetId: () => 'chat-messages',
getChatEl: () => ({ messages: null }),
getChatEl: () => ({ messages: null }),
- refreshVibeWidgetFeed: () => Promise.resolve(),
+ refreshFeedCard: () => Promise.resolve(),
hydrateActiveChatHistory: () => Promise.resolve(),
hydrateActiveChatHistory: () => Promise.resolve(),
CHAT_RECONNECT_BASE: 1000,
CHAT_RECONNECT_BASE: 1000,
console,
console,
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* recut, trim, bulk actions (which take a list of ids), thumbnail regeneration, and uploading
* recut, trim, bulk actions (which take a list of ids), thumbnail regeneration, and uploading
* recording chunks / a live thumbnail into someone else's stream;
* recording chunks / a live thumbnail into someone else's stream;
* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
- * answer would be the new key), the slot's vibe-coding settings;
+ * answer would be the new key) (a slot's vibe coding feed is OpenVibe.Chat's since 2026-10-24);
* - restream destinations (each holds a platform stream key): edit, delete, start, stop;
* - restream destinations (each holds a platform stream key): edit, delete, start, stop;
* - API tokens.
* - API tokens.
*
*
@@ -88,7 +88,6 @@ app.use('/api/restream', require('../server/streaming/restream-routes'));
@@ -88,7 +88,6 @@ app.use('/api/restream', require('../server/streaming/restream-routes'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/clips', require('../server/media-proxy/clips'));
app.use('/api/clips', require('../server/media-proxy/clips'));
app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
-app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));
const server = http.createServer(app).listen(0, '127.0.0.1');
const server = http.createServer(app).listen(0, '127.0.0.1');
let ipSeq = 0;
let ipSeq = 0;
@@ -239,12 +238,6 @@ async function check(name, fn) {
@@ -239,12 +238,6 @@ async function check(name, fn) {
assert.strictEqual((await db.getUserById(ALICE)).stream_key, '3'.repeat(32));
assert.strictEqual((await db.getUserById(ALICE)).stream_key, '3'.repeat(32));
assert.strictEqual((await row('managed_streams', slotA)).stream_key, SLOT_KEY);
assert.strictEqual((await row('managed_streams', slotA)).stream_key, SLOT_KEY);
});
});
- await check('vibe-coding: Bob cannot change Alice\'s slot settings', async () => {
- refused(await call('PUT', `/api/vibe-coding/managed/${slotA}/settings`, BOB, { enabled: true, repo: 'bob/pwn' }), 'PUT settings');
- refused(await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, BOB), 'GET settings');
- refused(await call('GET', `/api/vibe-coding/managed/${slotA}/events`, BOB), 'GET events');
- assert.strictEqual((await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, ALICE)).status, 200, 'control: Alice reads hers');
- });
await check('control: Alice can edit her own slot', async () => {
await check('control: Alice can edit her own slot', async () => {
const r = await call('PUT', `/api/streams/managed/${slotA}`, ALICE, { title: 'Alice main' });
const r = await call('PUT', `/api/streams/managed/${slotA}`, ALICE, { title: 'Alice main' });
assert.strictEqual(r.status, 200, r.text.slice(0, 200));
assert.strictEqual(r.status, 200, r.text.slice(0, 200));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+// Vibe coding feeds are OpenVibe.Chat's since 2026-10-24 (its server/feeds/, openvibe.chat/feeds; the owner: "migrate
+// that whole entire feature so it's built into the openvibe.chat system"). Nothing under server/ or scripts/ reads or
+// writes Live's vibe_coding_sessions or vibe_coding_events (Chat's scripts/import-live-feeds.js copies them; a contract
+// migration drops them once the copy is verified), the publisher socket and the old routes answer 410 naming Chat, and
+// the channel chat shows Chat's own card instead of Live's widget.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const NAMES = /\b(vibe_coding_sessions|vibe_coding_events)\b/;
+const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
+
+function walk(dir, out = []) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
+ }
+ return out;
+}
+
+const offenders = [];
+for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
+ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
+ if (!isCommentLine(line) && NAMES.test(line)) offenders.push(`${path.relative(ROOT, file)}:${i + 1}: ${line.trim().slice(0, 120)}`);
+ });
+}
+assert.deepStrictEqual(offenders, [], 'vibe coding feeds are OpenVibe.Chat\'s: nothing here reads Live\'s old tables');
+assert.ok(!fs.existsSync(path.join(ROOT, 'server/vibe-coding')), 'Live runs no feed server');
+
+const { classify } = require('../server/net/ws-paths');
+assert.deepStrictEqual(classify('/ws/vibe-coding/publish?token=x'), { status: '410 Gone', text: 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds' });
+
+const index = fs.readFileSync(path.join(ROOT, 'server/index.js'), 'utf8');
+assert.match(index, /app\.use\('\/api\/vibe-coding', \(req, res\) => res\.status\(410\)/, 'the old routes say where feeds went');
+assert.match(index, /app\.get\('\/api\/vibe-coding\/channel\/:username\/:slot\/events', \(req, res\) => res\.json\(\{ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF,/, 'an N-1 page gets a feed that is off');
+assert.match(index, /const VIBE_CODING_OFF = Object\.freeze\(\{ enabled: 0,/);
+assert.match(index, /frameSrc: \[[^\]]*"https:\/\/openvibe\.chat"/, 'the card frames Chat\'s embed');
+assert.match(index, /connectSrc: \[[^\]]*"https:\/\/openvibe\.chat"/, 'and asks Chat which feed a slot shows');
+
+const chat = fs.readFileSync(path.join(ROOT, 'public/js/chat.js'), 'utf8');
+assert.ok(!/_vibeWidgetState|refreshVibeWidgetFeed|\/vibe-coding\/channel\//.test(chat), 'Live\'s own widget is gone');
+assert.match(chat, /\$\{FEED_ORIGIN\}\/api\/chat\/feeds\?slot=/);
+assert.match(chat, /\$\{FEED_ORIGIN\}\/embed\/feed\//);
+assert.match(chat, /e\.origin !== FEED_ORIGIN/, 'the card\'s height comes only from Chat\'s frame');
+console.log('vibe coding: Chat\'s; nothing reads Live\'s old tables, the old socket and routes answer 410, the channel shows Chat\'s card');
deleted file mode 100644
deleted file mode 100644
@@ -1,78 +0,0 @@
@@ -1,78 +0,0 @@
-/**
- * Vibe-coding publication needs the narrow `vibe_coding_publish` scope.
- *
- * /ws/vibe-coding/publish and the /api/vibe-coding writes used to accept the broad `stream` scope
- * too, so any stream-control token (live state, restreams, VODs, clips) could publish a coding feed.
- * Now an hbt_ token with `stream` alone is closed with 4403 / refused, and a token with
- * `vibe_coding_publish` (the "GitHub Copilot Companion" preset) keeps working. Session sign-in is
- * unaffected. (Moving publication to Network project credentials in OpenVibe.Codes is later work.)
- *
- * node test/vibe-coding-scope.test.js
- */
-'use strict';
-const assert = require('assert');
-const http = require('http');
-
-process.env.NODE_ENV = 'test';
-const quiet = console.log;
-console.log = () => {};
-console.warn = () => {};
-
-const db = require('../server/db/database');
-const auth = require('../server/auth/auth');
-const WebSocket = require('ws');
-const VibeCodingPublishServer = require('../server/vibe-coding/publish-server');
-
-let slot;
-
-/** Connect and return how the socket ended up: { code } when closed, or { ready } on vibe-coding.ready. */
-function connect(port, token) {
- return new Promise((resolve) => {
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/vibe-coding/publish?token=${token}&managedStreamId=${slot}&slotSlug=camp-code`);
- ws.on('message', (data) => {
- const msg = JSON.parse(String(data));
- if (msg.type === 'vibe-coding.ready') { ws.close(); resolve({ ready: true }); }
- });
- ws.on('close', (code) => resolve({ code }));
- ws.on('error', () => {});
- });
-}
-
-(async () => {
- await db.initDb();
- await db.getDb().prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE VALUES (7, 'coder', 'coder', 'c@x', 'x', 'streamer')`).run();
- await db.createManagedStream({ user_id: 7, slug: 'camp-code', title: 'code', stream_key: 'key-code' });
- slot = Number((await db.get('SELECT id FROM managed_streams WHERE user_id = 7 AND slug = ?', ['camp-code'])).id);
- const narrow = (await db.createApiToken(7, 'Copilot Companion', ['read', 'vibe_coding_publish'])).token;
- const broad = (await db.createApiToken(7, 'Stream Controller', ['read', 'stream', 'control'])).token;
-
- // Scope check itself.
- const { hasVibeCodingPublishScope } = VibeCodingPublishServer;
- assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['vibe_coding_publish'] }), true);
- assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');
-
- // The real WebSocket endpoint with real hbt_ tokens.
- const pub = new VibeCodingPublishServer(db);
- const server = http.createServer();
- pub.init(server);
- server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
- const port = server.address().port;
- assert.deepStrictEqual(await connect(port, broad), { code: 4403 }, 'a stream-scoped token is closed with 4403');
- assert.deepStrictEqual(await connect(port, narrow), { ready: true }, 'a vibe_coding_publish token still publishes');
- assert.deepStrictEqual(await connect(port, 'hbt_' + '0'.repeat(64)), { code: 4401 }, 'an unknown token is refused');
- server.close();
-
- // REST: the /api/vibe-coding writes take only the narrow scope.
- const t = (method, url, scopes) => auth.apiTokenAllows({ method, originalUrl: url }, scopes);
- assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'stream', 'control']), false, 'stream no longer writes vibe-coding settings');
- assert.strictEqual(t('PUT', `/api/vibe-coding/managed/${slot}/settings`, ['read', 'vibe_coding_publish']), true);
- assert.strictEqual(t('GET', `/api/vibe-coding/managed/${slot}/events`, ['read']), true, 'reads are unchanged');
- assert.strictEqual(t('POST', '/api/streams/managed', ['stream']), true, 'the stream scope still covers stream control');
-
- quiet('vibe-coding-scope: ok');
- process.exit(0);
-})().catch((err) => {
- quiet(err);
- process.exit(1);
-});
@@ -12,7 +12,7 @@ const { classify, refuse, SERVED } = require('../server/net/ws-paths');
@@ -12,7 +12,7 @@ const { classify, refuse, SERVED } = require('../server/net/ws-paths');
(async () => {
(async () => {
assert.strictEqual(classify('/ws/broadcast?stream=1'), 'served');
assert.strictEqual(classify('/ws/broadcast?stream=1'), 'served');
- assert.strictEqual(classify('/ws/vibe-coding/publish'), 'served');
+ assert.deepStrictEqual(classify('/ws/vibe-coding/publish?token=x'), { status: '410 Gone', text: 'Vibe coding feeds moved to OpenVibe.Chat: https://openvibe.chat/feeds' });
assert.strictEqual(classify('/ws/robotstreamer-publish?key=x'), 'served');
assert.strictEqual(classify('/ws/robotstreamer-publish?key=x'), 'served');
assert.deepStrictEqual(classify('/ws/control'), { status: '410 Gone', text: 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' });
assert.deepStrictEqual(classify('/ws/control'), { status: '410 Gone', text: 'Robot control moved to OpenVibe.Bot: https://openvibe.bot' });
assert.deepStrictEqual(classify('/ws/control?robot=7'), classify('/ws/control'));
assert.deepStrictEqual(classify('/ws/control?robot=7'), classify('/ws/control'));