openvibers/openvibe.shared Commits
4.168.0: the Dock v2 (runs, overview, Vibe list) and the Feed composer's pictures (#386)
Changes
@@@ -1,5 +1,45 @@# Changelog++## 4.168.0 — 2026-11-09++++- **The Dock's Agents tab, content-aware** (plan 2026-11-09 §7–8; the owner, 11-09: "the Agents tab's 34 identical rows++ 'Draft a blog post · Done · AI · gpt-5-nano', 25 failed rows 'AI is resting for a moment' and raw JSON errors; an empty++ Agents tab that is wasted space for no reason"). `dock-runs.js` (loaded with the tab) draws each run as a row: the++ skill's icon in its own colour, the subject as the title, and one status line — Running (the live step and a thin++ bar), Waiting (what it needs), Scheduled (when), Done (the result, the cost, the time, the model with its maker's++ mark) or Failed (the words and what happens next). Open, Retry, Stop and "run again with another model" are on the++ row. Six identical failures in a day are one row, `×6`, that expands. A failure never shows the provider's own text.++- **The Agents overview** (instead of an empty middle): the next scheduled run with a countdown, today's and this++ week's numbers (runs, done, failed, spend) as three small tiles, the last three results as rows, and "Things agents++ can do for you": a grid of six skills from Actor's catalogue that start a run in one tap. The foot stays one row:++ "All runs" and "Give an agent a task".++- **The Vibe tab has a conversation list** (`dock-vibe.js`): New conversation at the top, then each chat with its last++ line, its time and the site it began on; a back arrow in a conversation. The tab opens on the conversation you had++ open within the last twenty minutes, else on the list, and the choice is the Dock's synced state, so it follows you++ across pages and sites. "Vibe did not answer" says why and offers a retry. The composer shows at once, with the++ attach and the mic.++- **The Chats tab has an AI chats row** among the DMs, placed by date like the others: the Vibe mark, "AI chats", the++ count, the last conversation's title and when Vibe answered; tapping it opens the Vibe tab on its list. A group's row++ shows up to three faces, not a number sign.++- **The Dock keeps its size and corner per person** and its grip is always visible; the Vibe conversation and the++ Agents filter are restored with it.++- **Restyled here (Opus):** a run is a row, not a box: the state as a coloured word (Running blue with a moving bar,++ Waiting amber, Done green, Failed red), its words on two lines at most, the title opens it, Approve and Retry as filled++ pills, the rest under a dots button. The tiles lead with their number (11 runs) and dots for done and failed. The Vibe++ list gives each conversation its mark and its site's name (Blog, not openvibe.blog) and New conversation is a button.++ The AI chats row has a Vibe avatar like every other row; group faces are ringed in the panel's own colour.++- The one-tap skills are read through Network (`/api/v1/me/actions/skills`): every site's CSP refuses openvibe.actor.++- The extension's read-only Dock offers no approve, decline, stop or retry.++- **Early hints: the Link header only, by default** (`openvibe-shared/sw` `earlyHints`). nginx 1.28, the host's, takes an++ upstream 103 as the final response and sends the real 200 as its body (tried 11-09 against nginx:1.28-alpine). The 103++ goes out only with `{ send103: true }` or `OV_EARLY_HINTS=1`, for a proxy that passes it on (nginx 1.29+ with++ `early_hints`). Cloudflare's Early Hints read the Link header.++- **The Feed's composer takes pictures** (the owner, 11-09: "you can make posts and put images"). Up to four, each++ with a line saying what it shows (Community requires the alt text; Post stays off until each has one). Big phone++ photos are scaled to 2560 px before they go. Each picture uploads through Network (`POST /api/frame/attachments`,++ Network #719) and the post names them (`POST /api/frame/posts`). Previews are small data: images, because not++ every site's img-src allows blob:.++## 4.167.0 — 2026-11-09- **The Feed, everything on the network, day by day** (the owner: "one huge feed of everything… all kinds of events and@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.167.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.168.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.167.0",++ "version": "4.168.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,16 @@++/* Generated by scripts/build-ai-marks-client.js from ai-marks-data.js. */++(function (root) {++ 'use strict';++ if (!root || root.OpenVibeAIMarks) return;++ const DATA = {"OpenAI":"<path d=\"M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z\"></path>","Anthropic":"<path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\"></path>","Google":"<path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\"></path>","Gemma":"<path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\"></path>","Meta":"<path d=\"M6.897 4c1.915 0 3.516.932 5.43 3.376l.282-.373c.19-.246.383-.484.58-.71l.313-.35C14.588 4.788 15.792 4 17.225 4c1.273 0 2.469.557 3.491 1.516l.218.213c1.73 1.765 2.917 4.71 3.053 8.026l.011.392.002.25c0 1.501-.28 2.759-.818 3.7l-.14.23-.108.153c-.301.42-.664.758-1.086 1.009l-.265.142-.087.04a3.493 3.493 0 01-.302.118 4.117 4.117 0 01-1.33.208c-.524 0-.996-.067-1.438-.215-.614-.204-1.163-.56-1.726-1.116l-.227-.235c-.753-.812-1.534-1.976-2.493-3.586l-1.43-2.41-.544-.895-1.766 3.13-.343.592C7.597 19.156 6.227 20 4.356 20c-1.21 0-2.205-.42-2.936-1.182l-.168-.184c-.484-.573-.837-1.311-1.043-2.189l-.067-.32a8.69 8.69 0 01-.136-1.288L0 14.468c.002-.745.06-1.49.174-2.23l.1-.573c.298-1.53.828-2.958 1.536-4.157l.209-.34c1.177-1.83 2.789-3.053 4.615-3.16L6.897 4zm-.033 2.615l-.201.01c-.83.083-1.606.673-2.252 1.577l-.138.199-.01.018c-.67 1.017-1.185 2.378-1.456 3.845l-.004.022a12.591 12.591 0 00-.207 2.254l.002.188c.004.18.017.36.04.54l.043.291c.092.503.257.908.486 1.208l.117.137c.303.323.698.492 1.17.492 1.1 0 1.796-.676 3.696-3.641l2.175-3.4.454-.701-.139-.198C9.11 7.3 8.084 6.616 6.864 6.616zm10.196-.552l-.176.007c-.635.048-1.223.359-1.82.933l-.196.198c-.439.462-.887 1.064-1.367 1.807l.266.398c.18.274.362.56.55.858l.293.475 1.396 2.335.695 1.114c.583.926 1.03 1.6 1.408 2.082l.213.262c.282.326.529.54.777.673l.102.05c.227.1.457.138.718.138.176.002.35-.023.518-.073.338-.104.61-.32.813-.637l.095-.163.077-.162c.194-.459.29-1.06.29-1.785l-.006-.449c-.08-2.871-.938-5.372-2.2-6.798l-.176-.189c-.67-.683-1.444-1.074-2.27-1.074z\"></path>","DeepSeek":"<path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\"></path>","Qwen":"<path d=\"M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z\"></path>","Mistral":"<path clip-rule=\"evenodd\" d=\"M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z\"></path>","Kimi":"<path d=\"M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z\"></path><path d=\"M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z\"></path>","GLM":"<path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\"></path>","MiniMax":"<path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\"></path>","Microsoft":"<path d=\"M11.49 2H2v9.492h9.492V2h-.002z\"></path><path d=\"M22 2h-9.492v9.492H22V2z\"></path><path d=\"M11.49 12.508H2V22h9.492v-9.492h-.002z\"></path><path d=\"M22 12.508h-9.492V22H22v-9.492z\"></path>","xAI":"<path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path>","NVIDIA":"<path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\"></path>","Cohere":"<path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\"></path>","Perplexity":"<path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\"></path>"};++ const ALIAS = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google', gemma: 'Gemma', meta: 'Meta', deepseek: 'DeepSeek', qwen: 'Qwen', mistral: 'Mistral', kimi: 'Kimi', glm: 'GLM', minimax: 'MiniMax', microsoft: 'Microsoft', xai: 'xAI', nvidia: 'NVIDIA', cohere: 'Cohere', perplexity: 'Perplexity' };++ const mark = (maker, model) => {++ const text = String(maker || model || '').toLowerCase();++ const key = Object.keys(ALIAS).find((name) => text.includes(name)) || (/gpt|\bo[134]\b/.test(text) ? 'openai' : /claude/.test(text) ? 'anthropic' : /gemini/.test(text) ? 'google' : /grok/.test(text) ? 'xai' : null);++ if (!key || !DATA[ALIAS[key]]) return null;++ const tile = document.createElement('span'); tile.className = 'ovdk-run-maker'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', ALIAS[key]);++ tile.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + DATA[ALIAS[key]] + '</svg>';++ return tile;++ };++ root.OpenVibeAIMarks = { mark };++})(typeof window !== 'undefined' ? window : null);nnew file mode 100644@@@ -0,0 +1,135 @@++/** The Dock's content-aware run list. All content comes from Network's action feed and Actor's skill catalogue. */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockRuns) return;++ const CSS = `++.ovdk-overview{display:grid;gap:8px;padding:4px 10px 12px}.ovdk-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}++.ovdk-tile{min-width:0;display:grid;align-content:start;gap:2px;padding:10px 11px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#ececf1) 5%,transparent)}++.ovdk-tile small{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#8b8b96)}++.ovdk-tile b{font-size:22px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums;letter-spacing:-.02em}.ovdk-tile b em{font-style:normal;font-size:12px;font-weight:650;color:var(--text-secondary,#a8a8b2);letter-spacing:0;margin-left:4px}++.ovdk-tile span{white-space:pre-wrap;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--text-secondary,#a8a8b2);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.ovdk-tile .ovdk-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 3px 1px 0;background:var(--ok,#3fb27f)}.ovdk-tile .ovdk-dot.is-bad{background:var(--live-red,#e5484d)}++.ovdk-tile-next b{font-size:15px;line-height:1.25;letter-spacing:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.ovdk-tile-next span{color:var(--accent-light,var(--accent,#60a5fa));font-weight:700}++.ovdk-overview h3{font-size:11px;font-weight:750;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted,#8b8b96);margin:8px 2px 0}.ovdk-skills{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}++.ovdk-skills button{min-height:44px;padding:9px 11px;border-radius:12px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);color:inherit;text-align:left;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650}++.ovdk-skills button:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}.ovdk-overview .ovdk-runs{padding:0}.ovdk-run-filters{display:flex;gap:4px;overflow-x:auto;padding:8px 10px;scrollbar-width:none}.ovdk-run-filters::-webkit-scrollbar{display:none}++.ovdk-run-filters button{min-height:36px;padding:0 11px;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:99px;background:transparent;color:inherit;cursor:pointer;white-space:nowrap;font:inherit;font-size:12px}++.ovdk-run-filters button[aria-pressed=true]{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}++.ovdk-runs{list-style:none;margin:0;padding:0 6px 10px;display:grid;gap:2px}.ovdk-run{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:11px;padding:10px 8px;border-radius:14px;min-width:0}++.ovdk-run-more{position:relative;align-self:start}.ovdk-run-more summary{list-style:none;width:36px;height:36px;display:grid;place-items:center;border-radius:50%;cursor:pointer;color:var(--text-muted,#8b8b96)}++.ovdk-run-more summary::-webkit-details-marker{display:none}.ovdk-run-more summary svg{width:18px;height:18px;fill:currentColor}++@media (hover:hover){.ovdk-run-more summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent);color:var(--text-primary,#ececf1)}}++.ovdk-run-more menu{position:absolute;right:0;top:38px;z-index:3;min-width:210px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:12px;background:var(--bg-primary,#19191f);box-shadow:0 10px 28px rgba(0,0,0,.28)}++.ovdk-run-more menu a,.ovdk-run-more menu button{display:flex;align-items:center;width:100%;min-height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:inherit;font:inherit;font-size:13px;text-decoration:none;cursor:pointer;text-align:left}++.ovdk-run-more menu a:hover,.ovdk-run-more menu button:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}++@media(max-width:640px){.ovdk-run-more summary{width:44px;height:44px}.ovdk-run-more menu{top:46px}}++@media (hover:hover){.ovdk-run:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}}++.ovdk-run-st{font-weight:750;margin-right:4px}.ovdk-run[data-state=running] .ovdk-run-st{color:var(--accent-light,var(--accent,#60a5fa))}.ovdk-run[data-state=waiting] .ovdk-run-st{color:#f0a73a}++.ovdk-run[data-state=scheduled] .ovdk-run-st{color:var(--text-primary,#ececf1)}.ovdk-run[data-state=done] .ovdk-run-st{color:var(--ok,#3fb27f)}.ovdk-run[data-state=failed] .ovdk-run-st{color:var(--live-red,#e5484d)}++.ovdk-run-ic{width:34px;height:34px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb,var(--run-color,var(--accent,#3b82f6)) 15%,transparent);color:var(--run-color,var(--accent,#3b82f6))}++.ovdk-run-sub{min-width:0;display:grid;gap:3px}.ovdk-run-sub>b,.ovdk-run-title b{display:block;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.ovdk-run-title{color:inherit;text-decoration:none;min-width:0}.ovdk-run-title:hover b{text-decoration:underline;text-underline-offset:2px}.ovdk-run-line{font-size:12.5px;color:var(--text-secondary,#a8a8b2);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.ovdk-run-model{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--text-secondary,#a8a8b2)}.ovdk-run-maker{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent)}.ovdk-run-maker svg{width:14px;height:14px;fill:currentColor}++.ovdk-run-bar{height:3px;margin:2px 0;background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent);border-radius:9px;overflow:hidden}.ovdk-run-bar:after{content:'';display:block;width:40%;height:100%;border-radius:9px;background:var(--accent,#3b82f6);animation:ovdk-run-go 1.6s cubic-bezier(.45,0,.2,1) infinite}++@keyframes ovdk-run-go{from{transform:translateX(-100%)}to{transform:translateX(250%)}}@media (prefers-reduced-motion:reduce){.ovdk-run-bar:after{animation:none;width:45%}}++.ovdk-run-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:3px}.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary{min-height:32px;display:inline-flex;align-items:center;padding:0 12px;border-radius:999px;color:inherit;background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent);border:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650;text-decoration:none;list-style:none}++.ovdk-run-actions summary::-webkit-details-marker{display:none}.ovdk-run-actions .primary{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}++@media (hover:hover){.ovdk-run-actions a:hover,.ovdk-run-actions button:not(.primary):hover,.ovdk-run-actions summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 12%,transparent)}}++.ovdk-run-actions details{position:relative}.ovdk-run-actions details menu{position:absolute;right:0;z-index:2;min-width:180px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:10px;background:var(--bg-primary,#19191f);box-shadow:0 8px 24px rgba(0,0,0,.2)}++.ovdk-run-repeat{display:block;width:100%;min-height:36px;padding:0 10px;text-align:left;border:0;border-radius:10px;background:transparent;color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:12.5px;font-weight:650;cursor:pointer}++@media(max-width:640px){.ovdk-run-filters button,.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary,.ovdk-run-repeat{min-height:44px}.ovdk-skills button{min-height:52px}}++`;++ const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };++ const ago = (at) => { const n = Date.parse(at || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.round((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`; };++ const until = (at) => { const m = Math.max(0, Math.ceil((Date.parse(at || '') - Date.now()) / 60000)); return m < 1 ? 'now' : m < 60 ? `in ${m} min` : m < 1440 ? `in ${Math.ceil(m / 60)} h` : `in ${Math.ceil(m / 1440)} d`; };++ const cash = (n) => n == null ? '' : Number(n) < .01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`;++ const iconOf = (it) => { const s = String(it.skill || it.subject && it.subject.kind || it.kind || ''); return /post|blog|write|changelog|draft|patch/.test(s) ? ['fa-pen','#d7974d'] : /wiki|article|research/.test(s) ? ['fa-book','#9875d8'] : /image|pic|meme/.test(s) ? ['fa-image','#d66bad'] : /chat|thread/.test(s) ? ['fa-comments','#61a9d7'] : /code|repo/.test(s) ? ['fa-code','#63b89c'] : /clip|stream|video/.test(s) ? ['fa-film','#dc866a'] : /quest/.test(s) ? ['fa-flag','#b9a74b'] : ['fa-sparkles','#8b92d9']; };++ const dots = () => { const NS = 'http://www.w3.org/2000/svg'; const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');++ [5.5, 12, 18.5].forEach((x) => { const c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', String(x)); c.setAttribute('cy', '12'); c.setAttribute('r', '1.7'); s.append(c); }); return s; };++ const isUrl = (u) => /^https:\/\//.test(String(u || ''));++ // Where a person picks the model a run uses, or adds their own key.++ const MODEL_PAGE = 'https://openvibe.actor/chat/model';++ function mount(panel, o) {++ const st = o.styles && o.styles(); if (st && !st.textContent.includes('.ovdk-overview{')) st.textContent += CSS;++ // A site's Dock passes no scopes (full behaviour); the extension passes its read-only set.++ const canDecide = !Array.isArray(o.scopes) || o.scopes.includes('actions.decide');++ let doc = null, busy = false, timer = 0, filter = o.state().agent_filter || 'all';++ const request = async (path, init) => o.call(path, init);++ const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];++ function row(it) {++ const li = h('li', 'ovdk-run ovac-item'); li.dataset.state = it.bucket; li.dataset.bucket = it.bucket;++ const [glyph, color] = iconOf(it); li.style.setProperty('--run-color', color);++ const ic = h('span', 'ovdk-run-ic'); const i = h('i', `fa-solid ${glyph}`); i.setAttribute('aria-hidden', 'true'); ic.append(i);++ const body = h('div', 'ovdk-run-sub');++ const title = it.subject && it.subject.title || it.title || 'Agent run';++ const url = it.subject && it.subject.url || it.href;++ if (isUrl(url)) { const t = h('a', 'ovdk-run-title'); t.href = url; t.target = '_blank'; t.rel = 'noopener'; t.append(h('b', '', title)); body.append(t); } else body.append(h('b', '', title));++ const error = it.run_error; let st = 'Done', line = '';++ if (it.bucket === 'running') { st = 'Running'; line = it.step || it.summary || 'Working'; }++ else if (it.bucket === 'waiting') { st = 'Waiting'; line = it.summary || 'Your answer is needed'; }++ else if (it.bucket === 'scheduled') { st = 'Scheduled'; line = it.next_run_at ? until(it.next_run_at) : 'Time pending'; }++ else if (it.bucket === 'failed') { st = 'Failed'; line = error ? `${error.words} ${error.next}` : 'This run could not finish. Open it for details.'; }++ else line = [it.summary, cash(it.cost_usd), it.duration_ms != null ? `${Math.round(it.duration_ms / 1000)}s` : ''].filter(Boolean).join(' · ');++ const ln = h('span', 'ovdk-run-line'); ln.append(h('span', 'ovdk-run-st', st), document.createTextNode(line)); body.append(ln);++ if (it.model) { const model = h('span', 'ovdk-run-model'); const mark = root.OpenVibeAIMarks && root.OpenVibeAIMarks.mark(it.maker, it.model); if (mark) model.append(mark); model.append(String(it.model)); body.append(model); }++ if (it.bucket === 'running') body.append(h('span', 'ovdk-run-bar'));++ const acts = h('div', 'ovdk-run-actions'); let more = null;++ if (canDecide && it.bucket === 'waiting' && (it.actions || []).includes('approve')) for (const action of ['approve','decline']) {++ const b = h('button', action === 'approve' ? 'primary' : '', action === 'approve' ? 'Approve' : 'Decline'); b.type = 'button'; b.dataset.do = action;++ b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/${action}`, { method: 'POST', body: '{}' }); b.textContent = action === 'approve' ? 'Approved' : 'Declined'; } catch { b.disabled = false; b.textContent = 'Try again'; } }); acts.append(b);++ }++++ // The error's own action says what the person can do about it: pick a model, add a key, or wait.++ const what = error && error.action;++ if (it.bucket === 'failed' && (what === 'pick_model' || what === 'add_key')) { const a = h('a', '', what === 'pick_model' ? 'Pick a model' : 'Add a key'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; acts.append(a); }++ if (canDecide && it.bucket === 'running' && (it.actions || []).includes('cancel')) { const b = h('button', '', 'Stop'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/cancel`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }++ if (canDecide && it.bucket === 'failed' && (it.actions || []).includes('retry')) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }++ if (canDecide && it.service === 'actor' && (it.kind === 'agent_task') && ['done', 'failed'].includes(it.bucket)) { const menu = h('details', 'ovdk-run-more'); const summary = h('summary'); summary.setAttribute('aria-label', 'More'); summary.title = 'More'; summary.append(dots()); menu.append(summary); const list = h('menu');++ const choose = h('li'); const a = h('a', '', 'Run again with another model'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; choose.append(a); list.append(choose);++ const rerun = h('li'); const b = h('button', '', 'Run again'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/actor/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); menu.open = false; refresh(); } catch { b.disabled = false; b.textContent = 'Try again'; } }); rerun.append(b); list.append(rerun); menu.append(list); more = menu; }++ if (acts.childNodes.length) body.append(acts); li.append(ic, body); if (more) li.append(more); if (root.OVGlyphs) root.OVGlyphs.swap(li); return li;++ }++ function grouped(items) { const map = new Map(); for (const it of items) { const day = String(it.updated_at || it.created_at || '').slice(0,10); const key = [it.skill || it.kind, it.subject && it.subject.kind || '', it.bucket, day].join('|'); if (!map.has(key)) map.set(key, []); map.get(key).push(it); } return [...map.values()]; }++ function rows(items, target) { const list = h('ul', 'ovdk-runs'); grouped(items).forEach((group) => { if (group.length === 1) list.append(row(group[0])); else { const li = row(group[0]); const b = h('button', 'ovdk-run-repeat', `×${group.length} similar runs · Show all`); const details = h('ul', 'ovdk-runs'); details.hidden = true; group.slice(1).forEach((it) => details.append(row(it))); b.addEventListener('click', () => { details.hidden = !details.hidden; b.textContent = details.hidden ? `×${group.length} similar runs · Show all` : 'Hide repeats'; }); li.querySelector('.ovdk-run-sub').append(b, details); list.append(li); } }); target.append(list); }++ async function skills(box) {++ try {++ // Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).++ const data = await request('/api/v1/me/actions/skills');++ const available = (data.listings || []).filter((x) => x && x.skill && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active').slice(0, 6);++ if (!available.length) return;++ box.append(h('h3', '', 'Things agents can do for you'));++ const grid = h('div', 'ovdk-skills');++ available.forEach((s) => {++ const b = h('button', '', s.skill.name); b.type = 'button'; b.setAttribute('aria-label', `Start ${s.skill.name}`);++ b.addEventListener('click', async () => {++ b.disabled = true;++ try {++ const out = await request(`/api/v1/me/actions/skills/${encodeURIComponent(s.id)}/run`, { method: 'POST', body: JSON.stringify({ input: {}, choice: { mode: 'balanced' } }) });++ if (out.run && out.run.id) root.location.href = `${o.actorBase}/tasks/${encodeURIComponent(out.run.id)}`;++ else refresh();++ } catch { b.disabled = false; b.textContent = `${s.skill.name} · Try again`; }++ });++ grid.append(b);++ });++ box.append(grid);++ } catch { /* catalogue unavailable */ }++ }++ function overview() { const box = h('section', 'ovdk-overview'); const tiles = h('div', 'ovdk-tiles'); const now = Date.now(); const items = all(); const next = (doc.buckets.scheduled || []).find((x) => x.next_run_at); const periods = [[1,'Today'],[7,'This week']];++ const first = h('div', 'ovdk-tile ovdk-tile-next'); first.append(h('small', '', 'Next'), h('b', '', next ? next.title : 'Nothing scheduled')); if (next) first.append(h('span', '', until(next.next_run_at))); tiles.append(first);++ periods.forEach(([days,label]) => { const recent = items.filter((x) => now - Date.parse(x.updated_at || x.created_at) < days * 86400000); const done = recent.filter((x) => x.bucket === 'done').length, failed = recent.filter((x) => x.bucket === 'failed').length; const cost = recent.reduce((n, x) => n + (Number(x.cost_usd) || 0), 0);++ const tile = h('div', 'ovdk-tile'); const n = h('b', '', String(recent.length)); n.append(h('em', '', recent.length === 1 ? 'run' : 'runs')); const sub = h('span');++ if (done) { sub.append(h('i', 'ovdk-dot'), document.createTextNode(`${done} `)); } if (failed) { sub.append(h('i', 'ovdk-dot is-bad'), document.createTextNode(`${failed} `)); } if (cost) sub.append(document.createTextNode(cash(cost))); if (!recent.length) sub.textContent = 'None yet';++ tile.setAttribute('aria-label', `${label}: ${recent.length} runs, ${done} done, ${failed} failed${cost ? `, ${cash(cost)}` : ''}`); sub.title = `${done} done · ${failed} failed`;++ tile.append(h('small', '', label), n, sub); tiles.append(tile); }); box.append(tiles);++ const recent = [...(doc.buckets.done || []), ...(doc.buckets.failed || [])].sort((a,b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0,3); if (recent.length) { box.append(h('h3', '', 'Last results')); rows(recent, box); }++ skills(box); return box; }++ function paint() { if (!doc) return; panel.textContent = ''; const items = all(); const filters = h('div', 'ovdk-run-filters'); for (const [id,label] of [['all','All'],['running','Running'],['waiting','Waiting'],['scheduled','Scheduled'],['done','Done'],['failed','Failed']]) { const b = h('button', '', label); b.type='button'; b.setAttribute('aria-pressed', String(filter===id)); b.addEventListener('click', () => { filter=id; o.save({agent_filter:id}); paint(); }); filters.append(b); } panel.append(filters);++ if (filter==='all' && !(doc.counts.running || doc.counts.waiting)) panel.append(overview());++ else { const shown = filter==='all' ? items : (doc.buckets[filter] || []); if (shown.length) rows(shown,panel); else panel.append(h('p','ovdk-none','No runs in this view.')); }++ const foot = o.foot || h('div','ovdk-foot'); foot.textContent=''; const allLink = h('a','','All runs'); allLink.href='https://openvibe.actor/tasks'; const give = h('a','','Give an agent a task'); give.href='https://openvibe.actor/skills'; foot.append(allLink,give); if (!o.foot) panel.append(foot); }++ async function refresh() { if (busy || !o.active()) return; busy=true; clearTimeout(timer); try { doc = await request('/api/v1/me/actions'); if (doc && doc.buckets) { paint(); if (o.onCounts) o.onCounts(doc.counts || {}); } } catch { panel.textContent=''; panel.append(h('div','ovdk-none','Your runs did not load. Open Agents again to retry.')); } finally { busy=false; if (o.active() && doc && doc.counts && doc.counts.running) timer=setTimeout(refresh,15000); } }++ refresh(); if (o.loadMarks) o.loadMarks().then((ok) => { if (ok && doc) paint(); });++ return { refresh, destroy: () => clearTimeout(timer) };++ }++ root.OpenVibeDockRuns = { mount };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -367,11 +367,11 @@let data;try { data = await api(path); } catch (err) {if (destroyed) return;-- nowBox.textContent = '';-- nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'The agent is not reachable right now.'));++ container.hidden = true;return;}if (destroyed) return;++ container.hidden = !data.now && !(data.needs_you || []).length && !(data.entries || []).length;renderNow(data.now);renderNeeds(data.needs_you || []);renderChips(data.counts || {});@@@ -12,6 +12,18 @@const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';const CSS = `.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }++ .ovdk-vibe-list { list-style: none; padding: 6px; margin: 0; display: grid; gap: 3px; }++ .ovdk-vibe-list button { width: 100%; min-height: 60px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }++ .ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }++ .ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }++ .ovdk-vibe-list small { justify-self: end; }++ .ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }++ .ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }++ .ovdk-vibe-list .ovdk-vibe-new { display: flex; gap: 8px; justify-content: center; min-height: 44px; margin: 2px 0 6px; border-radius: 999px; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); font-weight: 750; font-size: 14px; }++ .ovdk-vibe-list .ovdk-vibe-new:hover, .ovdk-vibe-list .ovdk-vibe-new:focus-visible { background: var(--accent-strong, var(--accent, #2563eb)); }++ .ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }.ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }.ovdk-away:hover { text-decoration: underline; }.ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }@@@ -23,7 +35,8 @@.ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }.ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }.ovdk-chip:hover { border-color: var(--accent, #3b82f6); }-- .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }++ .ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }++ .ovdk-attach:hover { color: var(--text-primary,#ececf1); }.ovdk-mic:hover { color: var(--text-primary, #ececf1); }.ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }.ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }@@@ -54,7 +67,7 @@// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text// (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).// Everything is set as text; a link is a link only when it is https.-- const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };++ const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;const TALK = Boolean(SR || MR);@@@ -115,15 +128,48 @@if (m.at) li.title = new Date(m.at).toLocaleString();return li;}++ const age = (iso) => { const n = Date.parse(iso || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.floor((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };++ async function openList() {++ clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;++ const panel = vibe.panel; if (!panel) return;++ panel.textContent = '';++ const head = h('div', 'ovdk-thread-h ovdk-vibe-h'); head.append(icon(G.vibe), h('b', '', 'Vibe conversations'));++ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener'; head.append(away);++ const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');++ const newer = h('button', 'ovdk-vibe-new', 'New conversation'); newer.type = 'button'; newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });++ const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);++ try {++ const out = await ctx.chatCall('/api/vibe/conversations');++ if (vibe.view !== 'list' || vibe.panel !== panel) return;++ const conversations = Array.isArray(out.conversations) ? out.conversations : [];++ if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));++ conversations.forEach((c) => {++ const li = h('li'), b = h('button'); b.type = 'button';++ const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));++ const line = h('span', '', c.last_line || 'No messages yet');++ // The site it began on, by name ("Blog", not openvibe.blog), beside the time.++ const host = String(c.site || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');++ const name = host ? (/^openvibe\.([a-z]+)$/.exec(host) || [])[1] : '';++ const site = h('small', '', name ? name.charAt(0).toUpperCase() + name.slice(1) : host);++ const mark = h('span', 'ovdk-vibe-site'); mark.append(icon(G.vibe));++ b.append(mark, title, time, line, site);++ b.setAttribute('aria-label', `${c.name || 'Conversation'}, ${age(c.last_message_at)}`);++ b.addEventListener('click', () => { vibe.fresh = false; vibe.chat = { slug: c.slug }; ctx.save({ vibe: { slug: c.slug, at: new Date().toISOString() } }); openVibe(); });++ li.append(b); list.append(li);++ });++ } catch (e) { list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); }++ }async function openVibe() {clearTimeout(vibe.timer);++ vibe.view = 'conversation';const panel = vibe.panel; if (!panel) return;vibe.msgs = []; vibe.last = 0;panel.textContent = '';const head = h('div', 'ovdk-thread-h ovdk-vibe-h');const title = h('b', '', 'Vibe');const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';-- head.append(icon(G.vibe), title, away);++ const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to Vibe conversations'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });++ head.append(back, icon(G.vibe), title, away);const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');@@@ -131,6 +177,29 @@const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));const err = h('p', 'ovdk-err'); err.hidden = true;++ const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'++ : e && e.status === 429 ? 'Too many requests were sent. Wait a minute, then retry.'++ : e && e.code === 'vibe.off' ? 'Vibe is unavailable right now. Retry when it returns.'++ : e && e.status === 503 ? 'The AI service is unavailable right now. Retry shortly.'++ : 'The request could not reach Vibe. Check your connection and retry.';++ const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;++ if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }++ };++ const attach = h('button', 'ovdk-attach', '+'); attach.type = 'button'; attach.setAttribute('aria-label', 'Attach a picture'); attach.title = 'Attach a picture';++ const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;++ attach.addEventListener('click', () => file.click());++ file.addEventListener('change', async () => {++ const picture = file.files && file.files[0]; if (!picture) return;++ attach.disabled = true; err.hidden = true;++ try {++ const path = vibe.chat ? `/api/chat/rooms/${encodeURIComponent(vibe.chat.slug)}/pictures` : '/api/chat/vibe/picture';++ const out = await ctx.chatCall(path, { method: 'POST', body: picture, type: picture.type || 'image/jpeg' });++ if (out.room && out.room.slug) { vibe.chat = { slug: out.room.slug }; vibe.fresh = false; ctx.save({ vibe: { slug: out.room.slug, at: new Date().toISOString() } }); }++ openVibe();++ } catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }++ finally { attach.disabled = false; file.value = ''; }++ });++ form.append(attach, file);let mic = null;if (TALK) {mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));@@@ -150,8 +219,8 @@vibe.busy = true; go.disabled = true; err.hidden = true;try {let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }-- const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });-- if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }++ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, fresh: vibe.fresh, timezone }) });++ if (out.chat) { vibe.chat = out.chat; vibe.fresh = false; away.href = out.chat.url; ctx.save({ vibe: { slug: out.chat.slug, at: new Date().toISOString() } }); }box.value = ''; fit();const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }@@@ -160,8 +229,7 @@scroll.scrollTop = scroll.scrollHeight;clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);} catch (x) {-- err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';-- err.hidden = false;++ showFailure(x, () => send(text));} finally { vibe.busy = false; go.disabled = false; box.focus(); }}form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });@@@ -169,13 +237,13 @@async function pull(first) {let got;try {-- got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);++ got = vibe.fresh && !vibe.chat ? { chat: null, messages: [] } : await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);} catch (e) {-- if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }++ if (first) { list.textContent = ''; showFailure(e, () => pull(true)); }return;}if (!ctx.active() || vibe.panel !== panel) return;-- if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }++ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; }if (first) list.textContent = '';const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;let answered = false;@@@ -186,7 +254,13 @@if (m.from === 'vibe') answered = true;if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();});-- if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }++ if (answered || Date.now() > vibe.waitUntil) {++ typing.remove();++ if (!answered && vibe.waitUntil && Date.now() > vibe.waitUntil) {++ showFailure({ status: 503 }, vibe.chat ? async () => { await ctx.chatCall(`/api/chat/vibe/${encodeURIComponent(vibe.chat.slug)}/retry`, { method: 'POST', body: '{}' }); err.hidden = true; vibe.waitUntil = Date.now() + 90000; list.append(typing); pull(); } : null);++ }++ vibe.waitUntil = 0;++ }if (first && !vibe.msgs.length) {const start = h('li', 'ovdk-vibe-start');start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));@@@ -281,13 +355,18 @@if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }vibe.panel = panel;-- openVibe();++ // The Chats tab's AI chats row asks for the list, not for the last conversation.++ const onList = ctx.wantList ? ctx.wantList() : false;++ const selected = ctx.state && ctx.state().vibe;++ if (!onList && selected && selected.slug && Date.now() - Date.parse(selected.at) < 20 * 60000) { vibe.chat = { slug: selected.slug }; openVibe(); }++ else openList();return {-- refresh: () => { vibe.panel = panel; return openVibe(); },++ refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); },++ list: () => { vibe.panel = panel; return openList(); },pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },talk: () => { if (vibe.talk) vibe.talk(); },// A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.-- ask: (text) => { if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },++ ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },};}root.OpenVibeDockVibe = { mount };@@@ -106,7 +106,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '40a5f8e3970b', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };++ const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': '3cac6118d6e9', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -222,7 +222,7 @@.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }.ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }.ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }-- .ovdk:hover .ovdk-size::after, .ovdk-size:active::after { opacity: .75; }++ .ovdk .ovdk-size::after { opacity: .75; }.ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }.ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }.ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }@@@ -249,6 +249,11 @@.ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }.ovdk-av img { width: 100%; height: 100%; object-fit: cover; }++ .ovdk-av-vibe { color: #fff; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent, #3b82f6) 70%, #a78bfa), var(--accent-strong, var(--accent, #2563eb))); }++ .ovdk-av-vibe svg { width: 22px; height: 22px; }++ .ovdk-av-group { display: flex; align-items: center; overflow: visible; background: transparent; }++ .ovdk-av-group .ovdk-av { width: 27px; height: 27px; margin-left: -12px; border: 2px solid var(--bg-elevated, var(--bg-secondary, #1d1f27)); font-size: 11px; }++ .ovdk-av-group .ovdk-av:first-child { margin-left: 0; }.ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }.ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }@@@ -272,7 +277,7 @@border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }.ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }.ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }-- .ovdk-go { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;++ .ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }.ovdk-go:disabled { opacity: .55; cursor: default; }.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }@@@ -525,7 +530,7 @@panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }-- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });return;}if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }@@@ -534,10 +539,10 @@mounts[id] = { refresh: null };panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';if (id === 'agent') {-- const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));++ const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount));if (!ok) return failed(panel, id);// K-D3: the person's own agent stream above the list; it hides itself when there is none.-- panel.innerHTML = `<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-runs-foot ovdk-foot"></div>';// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a// phone Now and Needs you are on screen before anything else.load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {@@@ -550,16 +555,13 @@load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });});-- const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {-- token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],++ const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {++ call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),++ styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),++ foot: panel.querySelector('.ovdk-runs-foot'),onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },});mounts[id] = m;-- panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {-- const anchor = e.currentTarget;-- close();-- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }-- });return;}const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));@@@ -579,7 +581,7 @@// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.-- const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null };++ const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null, vibeSummary: null };const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');const me = () => String(o.me || '');const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;@@@ -592,7 +594,7 @@const { type: _type, ...rest } = init;return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {const b = await r.json().catch(() => ({}));-- if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }++ if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; e.code = b.code || null; throw e; }return b;});}@@@ -606,6 +608,8 @@const m = Math.max(0, Math.round((Date.now() - t) / 60000));return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;};++ // "answered 4 min ago", and "answered just now" when it was this minute.++ const answered = (iso) => { const w = when(iso); return !w ? '' : w === 'now' ? 'answered just now' : `answered ${w} ago`; };function avatar(p, group) {const a = h('span', 'ovdk-av');const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';@@@ -613,6 +617,13 @@else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';return a;}++ function groupAvatar(c) {++ const people = others(c).slice(0, 3);++ if (!people.length) return avatar({}, false);++ const stack = h('span', 'ovdk-av ovdk-av-group');++ people.forEach((p) => stack.append(avatar(p, false)));++ return stack;++ }const others = (c) => (c.participants || []).filter((p) => p.username !== me());const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';function chatFoot(panel) {@@@ -627,19 +638,33 @@const panel = chats.panel; if (!panel) return;if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;-- try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }++ try {++ const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);++ chats.list = dm.conversations || [];++ chats.vibeSummary = ai && ai.count ? { kind: 'vibe', count: ai.count, latest: ai.conversations && ai.conversations[0], last_message_at: ai.conversations && ai.conversations[0] && ai.conversations[0].last_message_at } : null;++ } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }if (sharing) await sharing;if (chats.open || tab !== 'chats') return;panel.textContent = '';if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));const scroll = h('div', 'ovdk-scroll');-- if (!chats.list.length) {++ if (!chats.list.length && !chats.vibeSummary) {const none = h('div', 'ovdk-none');none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));scroll.append(none);} else {const ul = h('ul', 'ovdk-chats');-- chats.list.forEach((c) => {++ [...chats.list, ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {++ if (c.kind === 'vibe') {++ const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';++ const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'AI chats'), h('span', 'ovdk-chat-p', `${c.count} conversation${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));++ const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;++ const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);++ const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));++ b.append(av, body, meta); b.setAttribute('aria-label', `AI chats, ${c.count} conversations${at ? `, ${answered(at)}` : ''}`);++ b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });++ li.append(b); ul.append(li); return;++ }const li = h('li'), b = h('button', 'ovdk-chat');b.type = 'button';const unread = Number(c.unread_count) || 0;@@@ -649,7 +674,7 @@const meta = h('span', 'ovdk-chat-m');meta.append(h('time', '', when(c.last_message_at || c.updated_at)));if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));-- b.append(avatar(others(c)[0] || {}, !!c.is_group), body, meta);++ b.append(c.is_group ? groupAvatar(c) : avatar(others(c)[0] || {}, false), body, meta);b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);if (unread) li.className = 'is-unread';b.addEventListener('click', () => openChat(String(c.id), true));@@@ -745,6 +770,8 @@// every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from// any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).const vibe = { panel: null, api: null };++ // The Chats tab's AI chats row opens the Vibe tab on its list, not on the last conversation.++ let vibeOnList = false;const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);document.addEventListener('keydown', (e) => {if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;@@@ -22,6 +22,8 @@ const BROWSER = Object.freeze(['notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person++ 'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab++ 'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '6435ab824d71', 'nav-slots.js': 'a5af310d62e0' };++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -122,12 +122,28 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }--.ovh-compose { display: flex; gap: 10px; align-items: flex-start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);++.ovh-compose { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }.ovh-post:disabled { opacity: .5; cursor: default; }++.ovh-compose-main { display: grid; gap: 8px; min-width: 0; }++.ovh-compose-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }++.ovh-compose-bar .ovh-post { margin-left: auto; }++.ovh-compose-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovh-pic-btn { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 12px; background: transparent; color: var(--accent-light, var(--accent, #60a5fa)); cursor: pointer; }++.ovh-pic-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++.ovh-pic-btn:disabled { opacity: .4; cursor: default; background: transparent; }++.ovh-pic-btn svg, .ovh-pic-x svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ovh-pics { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.ovh-pics[hidden] { display: none; }++.ovh-pic { display: grid; grid-template-columns: 56px minmax(0, 1fr) 40px; gap: 8px; align-items: center; }++.ovh-pic img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }++.ovh-pic input { min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }++.ovh-pic-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }++.ovh-pic-x svg { width: 18px; height: 18px; }++.ovh-pic-x:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); }.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }@@@ -287,6 +303,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],x: [['path', { d: 'M6 6l12 12M18 6 6 18' }]],++ image: [['rect', { x: 3.5, y: 4.5, width: 17, height: 15, rx: 3 }], ['circle', { cx: 9, cy: 10, r: 1.8 }], ['path', { d: 'M4 17.5l4.6-4.4a1.5 1.5 0 0 1 2 0l2.4 2.3 2.2-2a1.5 1.5 0 0 1 2 0l3.3 3' }]],eye: [['path', { d: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z' }], ['circle', { cx: 12, cy: 12, r: 2.8 }]],heart: [['path', { d: 'M12 20s-7.2-4.4-8.9-8.8C1.9 7.9 4 4.8 7.2 4.8c1.9 0 3.4 1 4.8 2.9 1.4-1.9 2.9-2.9 4.8-2.9 3.2 0 5.3 3.1 4.1 6.4C19.2 15.6 12 20 12 20Z' }]],reply: [['path', { d: 'M20.5 11.6a8 8 0 0 1-11.7 7.1L4 20l1.3-4.4a8 8 0 1 1 15.2-4Z' }]],@@@ -734,28 +751,101 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } }).then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});}++ // A picture for a post: scaled to at most 2560 px on its long side (phone photos are often 12 MB; Community takes 8 MB),++ // and a small preview drawn to a data: address (every site's img-src allows data:, not all allow blob:).++ async function preparePicture(file) {++ const out = { file, thumb: '' };++ try {++ const bmp = await createImageBitmap(file);++ const draw = (w, hgt) => { const c = document.createElement('canvas'); c.width = w; c.height = hgt; c.getContext('2d').drawImage(bmp, 0, 0, w, hgt); return c; };++ const t = 112 / Math.max(bmp.width, bmp.height);++ out.thumb = draw(Math.max(1, Math.round(bmp.width * t)), Math.max(1, Math.round(bmp.height * t))).toDataURL('image/jpeg', 0.8);++ const k = Math.min(1, 2560 / Math.max(bmp.width, bmp.height));++ if ((k < 1 || file.size > 7.5e6) && !/gif/i.test(file.type)) {++ const blob = await new Promise((ok) => draw(Math.round(bmp.width * k), Math.round(bmp.height * k)).toBlob(ok, 'image/jpeg', 0.86));++ if (blob) out.file = new File([blob], `${String(file.name || 'picture').replace(/\.[a-z0-9]+$/i, '')}.jpg`, { type: 'image/jpeg' });++ }++ } catch { /* the original goes as it is */ }++ return out;++ }function composer(box, o) {const form = h('form', 'ovh-compose');const face = h('span', 'ovh-face');const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];++ const main = h('div', 'ovh-compose-main');const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");++ // Pictures (owner, 11-09: "you can make posts and put images"): up to four, each with a line saying what it shows,++ // which Community requires (alt text, for people who cannot see it).++ const pics = []; const list = h('ul', 'ovh-pics'); list.hidden = true;++ const bar = h('div', 'ovh-compose-bar');++ const add = h('button', 'ovh-pic-btn'); add.type = 'button'; add.setAttribute('aria-label', 'Add pictures'); add.title = 'Add pictures (up to 4)'; add.append(svgIcon('image'));++ const file = h('input'); file.type = 'file'; file.accept = 'image/jpeg,image/png,image/webp,image/gif'; file.multiple = true; file.hidden = true;++ const hint = h('small', 'ovh-compose-hint');const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;-- ta.addEventListener('input', () => { post.disabled = !ta.value.trim(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });-- form.append(face, ta, post);++ const ready = () => {++ const missing = pics.some((p) => !p.alt.value.trim());++ post.disabled = !ta.value.trim() || missing;++ hint.textContent = missing ? 'Describe each picture' : '';++ add.disabled = pics.length >= 4;++ list.hidden = !pics.length;++ };++ ta.addEventListener('input', () => { ready(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });++ add.addEventListener('click', () => file.click());++ file.addEventListener('change', async () => {++ const chosen = [...(file.files || [])].slice(0, 4 - pics.length); file.value = '';++ for (const f of chosen) {++ if (!/^image\//.test(f.type)) continue;++ const pr = await preparePicture(f);++ if (pr.file.size > 8 * 1024 * 1024) { note.hidden = false; note.textContent = `${f.name} is over 8 MB.`; continue; }++ const li = h('li', 'ovh-pic');++ const im = h('img'); im.alt = ''; if (pr.thumb) im.src = pr.thumb;++ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?'; alt.setAttribute('aria-label', `What ${f.name} shows`);++ const x = h('button', 'ovh-pic-x'); x.type = 'button'; x.setAttribute('aria-label', 'Remove this picture'); x.title = 'Remove'; x.append(svgIcon('x'));++ const entry = { file: pr.file, alt, li };++ x.addEventListener('click', () => { pics.splice(pics.indexOf(entry), 1); li.remove(); ready(); });++ alt.addEventListener('input', ready);++ li.append(im, alt, x); list.append(li); pics.push(entry);++ }++ ready();++ const empty = pics.find((p) => !p.alt.value.trim()); if (empty) empty.alt.focus();++ });++ bar.append(add, file, hint, post);++ main.append(ta, list, bar);++ form.append(face, main);const note = h('p', 'ovh-note'); note.hidden = true;++ const auth = { Authorization: `Bearer ${o.token}` };form.addEventListener('submit', async (e) => {e.preventDefault();-- const text = ta.value.trim(); if (!text) return;-- post.disabled = true;++ const text = ta.value.trim(); if (!text || post.disabled) return;++ post.disabled = true; add.disabled = true; note.hidden = true;++ const label = post.textContent;try {-- const r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });++ let r;++ if (pics.length) {++ const named = [];++ for (let k = 0; k < pics.length; k++) {++ post.textContent = pics.length > 1 ? `Picture ${k + 1} of ${pics.length}` : 'Sending the picture';++ const fd = new FormData(); fd.append('file', pics[k].file, pics[k].file.name || 'picture.jpg');++ const up = await fetch(`${o.frameBase || FRAME}/attachments`, { method: 'POST', credentials: 'omit', headers: auth, body: fd });++ const uj = await up.json().catch(() => ({}));++ const id = uj && uj.attachment && uj.attachment.media_id;++ if (!up.ok || !id) throw new Error(uj.detail || 'A picture did not upload. Try again in a moment.');++ named.push({ media_id: id, alt: pics[k].alt.value.trim() });++ }++ post.textContent = 'Posting';++ r = await fetch(`${o.frameBase || FRAME}/posts`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text, pictures: named }) });++ } else {++ r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text }) });++ }const j = await r.json().catch(() => ({}));if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');-- ta.value = ''; note.hidden = false; note.textContent = 'Posted. It is on your profile and in your followers’ feeds.';++ ta.value = ''; ta.style.height = ''; pics.splice(0).forEach((p) => p.li.remove());++ note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';if (o.onPosted) o.onPosted(j);-- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); post.disabled = false; }++ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); }++ post.textContent = label; ready();});box.append(form, note);}@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.167.0",++ "version": "4.168.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.167.0",++ "version": "4.168.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.167.0",++ "version": "4.168.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -51,6 +51,8 @@"./nav-slots": "./nav-slots.js","./overlay": "./overlay.js","./dock-vibe": "./dock-vibe.js",++ "./dock-runs": "./dock-runs.js",++ "./ai-marks-client": "./ai-marks-client.js","./dock-timer": "./dock-timer.js","./dock-share": "./dock-share.js","./dock-card": "./dock-card.js",nnew file mode 100644@@@ -0,0 +1,24 @@++#!/usr/bin/env node++'use strict';++// A lazy browser copy of ai-marks-data for the Dock's model receipts. Source and attribution stay in ai-marks-data.js.++const fs = require('fs');++const path = require('path');++const data = require('../ai-marks-data');++const src = `/* Generated by scripts/build-ai-marks-client.js from ai-marks-data.js. */++(function (root) {++ 'use strict';++ if (!root || root.OpenVibeAIMarks) return;++ const DATA = ${JSON.stringify(data)};++ const ALIAS = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google', gemma: 'Gemma', meta: 'Meta', deepseek: 'DeepSeek', qwen: 'Qwen', mistral: 'Mistral', kimi: 'Kimi', glm: 'GLM', minimax: 'MiniMax', microsoft: 'Microsoft', xai: 'xAI', nvidia: 'NVIDIA', cohere: 'Cohere', perplexity: 'Perplexity' };++ const mark = (maker, model) => {++ const text = String(maker || model || '').toLowerCase();++ const key = Object.keys(ALIAS).find((name) => text.includes(name)) || (/gpt|\\bo[134]\\b/.test(text) ? 'openai' : /claude/.test(text) ? 'anthropic' : /gemini/.test(text) ? 'google' : /grok/.test(text) ? 'xai' : null);++ if (!key || !DATA[ALIAS[key]]) return null;++ const tile = document.createElement('span'); tile.className = 'ovdk-run-maker'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', ALIAS[key]);++ tile.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + DATA[ALIAS[key]] + '</svg>';++ return tile;++ };++ root.OpenVibeAIMarks = { mark };++})(typeof window !== 'undefined' ? window : null);++`;++fs.writeFileSync(path.join(__dirname, '..', 'ai-marks-client.js'), src);@@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css','search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.--const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },++const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;@@@ -72,14 +72,16 @@ function linkHeader(link) {** sw.earlyHints(res, [ { href: '/shared/navbar.js?v=abc123', as: 'script' } ]);*-- * The 103 goes out first when the runtime can send one (Node 18.11 and later); the Link header rides on the page itself so-- * the hint survives a proxy that drops the 103. Returns true when a 103 was sent.++ * The Link header rides on the page. The 103 itself goes out only where the proxy in front passes it on: `{ send103: true }`++ * or OV_EARLY_HINTS=1 (nginx 1.29+ with `early_hints`). nginx 1.28 (the host's, 11-09) takes an upstream 103 as the final++ * response and sends the real 200 as its body, so the default is the header only; Cloudflare's Early Hints read it.++ * Returns true when a 103 was sent.*/--function earlyHints(res, links) {++function earlyHints(res, links, { send103 = process.env.OV_EARLY_HINTS === '1' } = {}) {const list = (Array.isArray(links) ? links : []).filter((l) => l && l.href);if (!list.length || !res) return false;let sent = false;-- if (typeof res.writeEarlyHints === 'function') {++ if (send103 && typeof res.writeEarlyHints === 'function') {try {res.writeEarlyHints({ link: list.map(linkHeader).filter(Boolean) });sent = true;@@@ -46,6 +46,49 @@ const ACTIONS = {scheduled: [], done: [], failed: [],},};++// The Dock v2's rows (plan 2026-11-09 §8): a skill, a subject, a model, a maker, a cost, a duration, a live step and,++// for a failure, safe words and the next step. Six identical failures in a day are one row.++const DOCK_ACTIONS = {++ generated_at: iso(2000), sources: { actor: 'ok', codes: 'ok' },++ counts: { running: 1, waiting: 1, scheduled: 1, done: 2, failed: 6 },++ buckets: {++ waiting: [item({ id: 'apr_1', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'waiting', state: 'waiting',++ skill: 'blog.write', subject: { kind: 'post', title: 'Patch notes draft', url: 'https://openvibe.blog/posts/42' },++ title: 'Patch notes draft', summary: 'It needs the release name to finish the notes.', href: 'https://openvibe.actor/tasks/t3',++ actions: ['approve', 'decline'] })],++ running: [item({ id: 'tsk_1', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'running', state: 'running',++ skill: 'changelog-draft', subject: { kind: 'post', title: 'Patch notes draft · OpenVibe blog', url: 'https://openvibe.blog/posts/42' },++ title: 'Patch notes draft · OpenVibe blog', step: 'Writing the release notes…', model: 'gpt-5-nano', maker: 'OpenAI',++ duration_ms: 42000, href: 'https://openvibe.actor/tasks/tsk_1', steps: 14 })],++ scheduled: [item({ id: 'tsk_4', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'scheduled', state: 'scheduled',++ skill: 'wiki-article', subject: { kind: 'wiki', title: 'The Dock, for beginners', url: 'https://openvibe.wiki/a/dock' },++ title: 'Make a wiki article', next_run_at: new Date(Date.now() + 42 * 60e3).toISOString(), href: 'https://openvibe.actor/tasks/tsk_4' })],++ done: [++ item({ id: 'tsk_5', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'done', state: 'succeeded',++ skill: 'changelog-draft', subject: { kind: 'post', title: '4.162.0 release notes', url: 'https://openvibe.blog/posts/39' },++ title: '4.162.0 release notes', summary: 'The draft is ready for review.', cost_usd: 0.0031, duration_ms: 68000,++ model: 'gpt-5-nano', maker: 'OpenAI', href: 'https://openvibe.blog/posts/39' }),++ item({ id: 'tsk_6', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'done', state: 'succeeded',++ skill: 'wiki-article', subject: { kind: 'wiki', title: 'What is a Dock?', url: 'https://openvibe.wiki/a/dock' },++ title: 'What is a Dock?', summary: 'The article is a draft with its sources.', cost_usd: 0.0124, duration_ms: 154000,++ model: 'claude-sonnet-4-5', maker: 'Anthropic', href: 'https://openvibe.wiki/a/dock' }),++ ],++ failed: Array.from({ length: 6 }, (_, i) => item({ id: `fail_${i}`, service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'failed', state: 'failed',++ skill: 'blog.write', subject: { kind: 'post', title: 'Draft a blog post', url: null }, title: 'Draft a blog post',++ error: 'No model could take this task.',++ run_error: { code: 'route.unavailable', words: 'No model could take this task.', next: 'Pick a model or add an AI key, then retry.', action: 'pick_model' },++ href: 'https://openvibe.actor/tasks/fail_0', updated_at: iso(3600e3 * (i + 1)) })),++ },++};++// The Dock v2's Chats row and the Vibe list: a person's AI conversations, newest first.++const DOCK_VIBE = {++ count: 12,++ conversations: [++ { slug: 'v-1', name: 'Patch notes for 4.162', last_message_at: iso(4 * 60e3), created_at: iso(2 * 3600e3), last_line: 'I wrote the first draft.', last_answer_at: iso(4 * 60e3), site: 'openvibe.blog' },++ { slug: 'v-2', name: 'What is on today?', last_message_at: iso(90 * 60e3), created_at: iso(26 * 3600e3), last_line: 'Three things, starting with the release.', last_answer_at: iso(90 * 60e3), site: 'openvibe.today' },++ { slug: 'v-3', name: 'Memes for the launch', last_message_at: iso(2 * 86400e3), created_at: iso(9 * 86400e3), last_line: 'Here are three, pick one.', last_answer_at: iso(2 * 86400e3), site: 'openvibe.meme' },++ ],++};const NOTES = [{ id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) },{ id: 2, type: 'mention', category: 'social', service: 'chat', title: 'kai mentioned you', message: 'look at the shots?', priority: 'normal', is_read: 0, created_at: iso(900e3) },@@@ -65,7 +108,7 @@ const vibeMsgs = [], asked = [], listened = [], undone = [];const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">--<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>++<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}@media(prefers-color-scheme:light){:root{--bg:#fff;--bg-primary:#fff;--bg-elevated:#fff;--bg-secondary:#f4f6fa;--text-primary:#18202f;--text-secondary:#5d6779;--border:#dce0e8;--accent:#2365dc;--on-accent:#fff}}</style></head><body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main><script src="/navbar.js"></script><script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin, dockAgentBase: location.origin,@@@ -85,7 +128,11 @@ const server = http.createServer((req, res) => {if (req.method === 'PUT') { const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); puts.push(b); scenario.state = { ...(scenario.state || defaultState()), ...b, place: { ...((scenario.state || defaultState()).place), ...(b.place || {}) } }; }return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });}-- if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);++ if (u.pathname === '/api/v1/me/actions') return json(200, scenario.actions || ACTIONS);++ // Actor's skill catalogue, as the Dock's overview reads it: through Network's actions.++ if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: (scenario.skills || [1, 2, 3, 4, 5, 6]).map((n) => ({++ id: `skl_${n}`, skill: { slug: `skill-${n}`, name: ['Make an image', 'Make a wiki article', 'Write a blog post', 'Improve an article', 'Make a meme', 'Summarise a thread'][n - 1] },++ scan: { state: 'passed' }, curator: { state: 'active' } })) });// Codes' agent view (K-D3): the stream, its two controls, and the still.if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the agent view is read with the person\'s own token');@@@ -132,6 +179,7 @@ const server = http.createServer((req, res) => {.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));return json(200, { messages: all.filter((m) => m.id > after) });}++ if (u.pathname === '/api/vibe/conversations') return json(200, scenario.vibe || { conversations: [], count: 0 });if (u.pathname === '/api/vibe/chat') {assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'Vibe is asked with the person\'s own token');const after = Number(u.searchParams.get('after')) || 0;@@@ -183,7 +231,7 @@ const PILL = `(() => { const p = document.getElementById('ovdk-pill'); if (!p ||clear: getComputedStyle(document.documentElement).getPropertyValue('--ov-dock-clear').trim() }; })()`;const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hidden) return null; const b = c.getBoundingClientRect();return { corner: c.dataset.corner, w: Math.round(b.width), h: Math.round(b.height), l: b.left, t: b.top, tab: (c.querySelector('[role=tab][aria-selected=true]') || {}).dataset?.tab,-- groups: [...c.querySelectorAll('.ovac-group')].map((g) => g.dataset.bucket), items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;++ groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.@@@ -216,6 +264,49 @@ async function drag(p, from, to) {const browser = await h.launch({ tmpDir });const fresh = async (width, height = 860) => { const context = await browser.newContext(); const p = await h.openPage(browser, context, { width, height }); return p; };try {++ if (process.env.DOCK_SHOTS_DIR) {++ fs.mkdirSync(process.env.DOCK_SHOTS_DIR, { recursive: true });++ const shot = async (p, name) => {++ await wait(400);++ const docWidth = await p.evaluate('document.documentElement.scrollWidth');++ assert.ok(docWidth <= p.width && docWidth >= p.width - 20, `${p.width} px: no sideways scroll (${docWidth})`);++ const pic = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });++ fs.writeFileSync(path.join(process.env.DOCK_SHOTS_DIR, `${name}-${p.width}-${p.scheme}.png`), Buffer.from(pic.data, 'base64'));++ };++ for (const width of [390, 1280]) for (const scheme of ['dark', 'light']) {++ // The Agents list: a running run, one waiting, one scheduled, two done and six identical failures.++ scenario = { counts: { running: 1, waiting: 1, unread: 0 }, actions: DOCK_ACTIONS, vibe: DOCK_VIBE };++ const p = await fresh(width, 900);++ p.width = width; p.scheme = scheme;++ await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!window.OpenVibeDock", 'the Dock');++ await p.evaluate("window.OpenVibeDock.open({ tab: 'agent' })");++ await until(p, "document.querySelectorAll('.ovdk-run').length >= 4", 'the screenshot runs');++ await shot(p, 'agents');++++ // The overview: nothing runs, so the tab says what is next, what happened and what agents can do.++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true },++ actions: { ...DOCK_ACTIONS, counts: { running: 0, waiting: 0, scheduled: 1, done: 2, failed: 6 } }, vibe: DOCK_VIBE };++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!window.OpenVibeDock", 'the Dock');++ await p.evaluate("window.OpenVibeDock.open({ tab: 'agent' })");++ await until(p, "!!document.querySelector('.ovdk-overview')", 'the overview');++ await shot(p, 'overview');++++ // The Vibe tab: the list of the person's conversations.++ await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");++ await until(p, "document.querySelectorAll('.ovdk-vibe-list button').length >= 3", 'the Vibe list');++ await shot(p, 'vibe');++++ // The Chats tab: the AI chats row among the DMs.++ await p.evaluate("document.querySelector('#ovdk-tab-chats').click()");++ await until(p, "!!document.querySelector('.ovdk-chat-b b')", 'the Chats rows');++ await shot(p, 'chats');++ await p.close();++ }++ if (process.env.DOCK_SHOTS_ONLY) return;++ }// ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──{scenario = { counts: { running: 0, waiting: 0, unread: 3 } };@@@ -537,6 +628,8 @@ async function drag(p, from, to) {await until(p, CARD, 'the card');assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");++ await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');++ await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');@@@ -582,6 +675,8 @@ async function drag(p, from, to) {await p.goto(`${base}/page`, { maxMs: 5000 });await until(p, PILL, 'the pinned pill');await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');++ await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++/**++ * The Dock's Agents tab (plan 2026-11-09 §7–8): the content-aware run rows and the overview that replaces an empty++ * middle. The rows say what a run was about, one status line says where it is, repeats fold into one row, and a++ * failure says why in words and what happens next — never the provider's own JSON.++ *++ * The classes are the design's: ovdk-run, ovdk-run-ic, ovdk-run-sub, ovdk-overview, ovdk-tiles, ovdk-skills and++ * ovdk-vibe-list. Opus refines from here, so the names are part of the contract.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const runs = fs.readFileSync(path.join(__dirname, '..', 'dock-runs.js'), 'utf8');++const vibe = fs.readFileSync(path.join(__dirname, '..', 'dock-vibe.js'), 'utf8');++const dock = fs.readFileSync(path.join(__dirname, '..', 'dock.js'), 'utf8');++const marks = fs.readFileSync(path.join(__dirname, '..', 'ai-marks-client.js'), 'utf8');++const builder = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-ai-marks-client.js'), 'utf8');++++// ── the classes the design names ────────────────────────────────────────────++for (const cls of ['ovdk-run', 'ovdk-run-ic', 'ovdk-run-sub', 'ovdk-overview', 'ovdk-tiles', 'ovdk-skills']) {++ assert.ok(runs.includes(cls), `the Agents tab has a ${cls}`);++}++assert.ok(vibe.includes('ovdk-vibe-list'), 'the Vibe tab has a list');++++// ── the row says what the run was about ─────────────────────────────────────++assert.ok(runs.includes('it.subject && it.subject.title'), 'the row is titled by its subject');++assert.ok(runs.includes('subject.kind'), 'the subject carries its kind');++assert.ok(runs.includes('skill'), 'the row names the skill');++assert.ok(runs.includes('iconOf'), 'the skill has its own icon');++for (const [skill, glyph] of [['post', 'fa-pen'], ['wiki', 'fa-book'], ['image', 'fa-image'], ['chat', 'fa-comments'], ['code', 'fa-code'], ['clip', 'fa-film'], ['quest', 'fa-flag']]) {++ assert.ok(runs.includes(glyph), `the ${skill} skill draws ${glyph}`);++}++assert.ok(/--run-color/.test(runs), 'the icon is in the skill\'s own colour');++++// ── one status line per state ───────────────────────────────────────────────++assert.ok(runs.includes("st = 'Running'"), 'a running run says what it is doing');++assert.ok(runs.includes("st = 'Waiting'"), 'a waiting run says what it needs');++assert.ok(runs.includes("st = 'Scheduled'"), 'a scheduled run says when');++assert.ok(runs.includes("st = 'Failed'"), 'a failed run says why');++assert.ok(runs.includes('cost_usd'), 'a finished run says what it cost');++assert.ok(runs.includes('duration_ms'), 'a finished run says how long it took');++assert.ok(runs.includes('it.model'), 'a finished run names the model');++assert.ok(runs.includes('maker'), 'the model carries its maker');++assert.ok(runs.includes('OpenVibeAIMarks'), 'the maker\'s mark is drawn');++assert.ok(runs.includes('ovdk-run-bar'), 'a running run has a thin bar');++++// ── a failure says why, and what happens next ───────────────────────────────++assert.ok(runs.includes('run_error'), 'the failure carries its safe error');++assert.ok(runs.includes('error.words'), 'the failure shows the words');++assert.ok(runs.includes('error.next'), 'the failure shows the next step');++assert.ok(!runs.includes('it.error_detail'), 'the raw provider text never reaches the row');++assert.ok(!/innerHTML\s*=\s*[^;]*error/.test(runs), 'no error is written as markup');++++// ── the row's actions ───────────────────────────────────────────────────────++assert.ok(runs.includes("h('a', 'ovdk-run-title')"), 'a row opens from its title');++assert.ok(runs.includes("'Retry'"), 'a failed run can be retried');++assert.ok(runs.includes("'Stop'"), 'a running run can be stopped');++assert.ok(runs.includes('Run again with another model'), 'a run can be made again with another model');++assert.ok(runs.includes('Pick a model'), 'a failure that needs a model says so');++assert.ok(runs.includes('Add a key'), 'and one that needs a key says so');++++// ── repeats fold into one row ───────────────────────────────────────────────++assert.ok(runs.includes('grouped'), 'the runs are grouped');++assert.ok(runs.includes('`×${group.length}'), 'a group says how many it holds');++assert.ok(runs.includes('ovdk-run-repeat'), 'a group expands');++++// ── the overview, instead of an empty middle ────────────────────────────────++assert.ok(runs.includes("h('small', '', 'Next')") && runs.includes('Nothing scheduled'), 'the overview says what runs next');++assert.ok(runs.includes('ovdk-tile'), 'the numbers are small tiles');++assert.ok(runs.includes('Today'), "the overview says today's numbers");++assert.ok(runs.includes('This week'), 'and this week\'s');++assert.ok(runs.includes('Last results'), 'the overview keeps the last results');++assert.ok(runs.includes('Things agents can do for you'), 'the overview offers the catalogue');++assert.ok(runs.includes('/api/v1/me/actions/skills'), 'the catalogue is Actor\'s, read through Network');++assert.ok(runs.includes("'All runs'"), 'the foot keeps All runs');++assert.ok(runs.includes("'Give an agent a task'"), 'and Give an agent a task');++++// ── the phone: 44 px, no spinners ───────────────────────────────────────────++assert.ok(/min-height:44px/.test(runs), 'every control is 44 px on a phone');++assert.ok(!runs.includes('spinner'), 'there is no spinner');++assert.ok(!runs.includes('innerHTML'), 'the tab writes no markup of its own');++++// ── the Vibe tab: a list, a back arrow, and why it did not answer ───────────++assert.ok(vibe.includes('openList'), 'the Vibe tab has a list');++assert.ok(vibe.includes('ovdk-vibe-back'), 'a conversation has a back arrow');++assert.ok(vibe.includes('20 * 60000'), 'the tab opens on the conversation you had within twenty minutes');++assert.ok(vibe.includes('ctx.state'), 'the choice is the Dock\'s synced state');++assert.ok(vibe.includes('ctx.save'), 'and it is written back');++assert.ok(vibe.includes('Vibe did not answer'), 'a failure says so');++assert.ok(vibe.includes("'Retry'"), 'and offers a retry');++assert.ok(vibe.includes('ovdk-attach'), 'the composer has an attach');++assert.ok(vibe.includes('ovdk-mic'), 'and the mic');++assert.ok(!vibe.includes('box.focus()') || /touch|coarse|pointer/.test(vibe), 'the composer does not take the focus on a touch screen');++++// ── the Dock loads them, and the Chats row opens the Vibe tab ──────────────++assert.ok(dock.includes("load('dock-runs.js'"), 'the Dock loads the Agents tab');++assert.ok(dock.includes("load('ai-marks-client.js'"), 'and the maker marks with it');++assert.ok(dock.includes('ovdk-runs-foot'), 'the Agents tab has its own foot');++assert.ok(dock.includes('AI chats'), "the Chats tab has the AI chats row");++assert.ok(dock.includes('vibeOnList'), 'and it opens the Vibe tab on its list');++assert.ok(dock.includes('groupAvatar'), "a group's row shows faces, not a number sign");++assert.ok(dock.includes('answered'), 'and when Vibe last answered');++assert.ok(dock.includes('ovdk-size::after') || dock.includes('.ovdk-size'), 'the Dock has a grip to resize it with');++++console.log("dock v2: the run rows, the overview, the Vibe list and the Chats row");@@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// time something is sent, not on every page.// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer// runs or its menu opens; the header's tab rail and narrow-width rules came with it.--const DOCK_BROTLI_BUDGET = 18944; // 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];++const DOCK_BROTLI_BUDGET = 19456; // 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];// ── the budget ──────────────────────────────────────────────{@@@ -50,6 +50,8 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut{ service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);++ if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);++ if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });if (s.startsWith('https://')) return J({}, false, 404);@@@ -193,8 +195,27 @@ const server = http.createServer((req, res) => {await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");++ // Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each++ // uploads through Network and the post names them with that text.++ await s.evaluate(`(async () => {++ const ta = document.querySelector('.ovh-compose textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input'));++ const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);++ const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));++ const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File([blob], 'b.png', { type: 'image/png' }));++ const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));++ })(), true`);++ assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');++ assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows');++ await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);++ assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on');++ await s.evaluate(`document.querySelector('.ovh-post').click(), true`);++ assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');++ const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);++ assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');++ assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');++ assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);await s.close();-- console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer, Network sync ok');++ console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');} finally {await browser.close();server.close();@@@ -36,22 +36,23 @@ const sw = require('../sw');assert.strictEqual(sw.linkHeader({ href: '/x.css', as: 'style', type: 'text/css' }), '</x.css>; rel=preload; as=style; type=text/css');assert.strictEqual(sw.linkHeader(null), '');-- // The 103 goes out first, and the Link header rides on the page so a proxy that drops the 103 still hints.++ // By default only the Link header: nginx 1.28 takes an upstream 103 as the final answer (11-09).let hints103 = null; const headers = {};const res = {headersSent: false,writeEarlyHints(h) { hints103 = h; return true; },setHeader(k, v) { headers[k] = v; },};-- assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), true);-- assert.deepStrictEqual(hints103, { link: ['</shared/navbar.js>; rel=preload; as=script'] });++ assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), false, 'no 103 by default');++ assert.strictEqual(hints103, null);assert.strictEqual(headers.Link, '</shared/navbar.js>; rel=preload; as=script');-- // A runtime that cannot send a 103 still gets the header.++ // Where the proxy passes a 103 on (send103 or OV_EARLY_HINTS=1), it goes first and the header still rides.let noHints = null; const headers2 = {};const res2 = { headersSent: false, writeEarlyHints(h) { noHints = h; }, setHeader(k, v) { headers2[k] = v; } };-- assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }]), true);-- assert.ok(noHints, 'the 103 was sent');++ assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }], { send103: true }), true);++ assert.deepStrictEqual(noHints, { link: ['</a.css>; rel=preload; as=style'] }, 'the 103 was sent');++ assert.strictEqual(headers2.Link, '</a.css>; rel=preload; as=style');const res3 = { headersSent: true, setHeader() { throw new Error('the header was already sent'); } };assert.strictEqual(sw.earlyHints(res3, [{ href: '/a.css', as: 'style' }]), false, 'a response already on its way is left alone');assert.strictEqual(sw.earlyHints(res, []), false, 'no links, no hint');
@@ -1,5 +1,45 @@
@@ -1,5 +1,45 @@
# Changelog
# Changelog
+## 4.168.0 — 2026-11-09
+
+- **The Dock's Agents tab, content-aware** (plan 2026-11-09 §7–8; the owner, 11-09: "the Agents tab's 34 identical rows
+ 'Draft a blog post · Done · AI · gpt-5-nano', 25 failed rows 'AI is resting for a moment' and raw JSON errors; an empty
+ Agents tab that is wasted space for no reason"). `dock-runs.js` (loaded with the tab) draws each run as a row: the
+ skill's icon in its own colour, the subject as the title, and one status line — Running (the live step and a thin
+ bar), Waiting (what it needs), Scheduled (when), Done (the result, the cost, the time, the model with its maker's
+ mark) or Failed (the words and what happens next). Open, Retry, Stop and "run again with another model" are on the
+ row. Six identical failures in a day are one row, `×6`, that expands. A failure never shows the provider's own text.
+- **The Agents overview** (instead of an empty middle): the next scheduled run with a countdown, today's and this
+ week's numbers (runs, done, failed, spend) as three small tiles, the last three results as rows, and "Things agents
+ can do for you": a grid of six skills from Actor's catalogue that start a run in one tap. The foot stays one row:
+ "All runs" and "Give an agent a task".
+- **The Vibe tab has a conversation list** (`dock-vibe.js`): New conversation at the top, then each chat with its last
+ line, its time and the site it began on; a back arrow in a conversation. The tab opens on the conversation you had
+ open within the last twenty minutes, else on the list, and the choice is the Dock's synced state, so it follows you
+ across pages and sites. "Vibe did not answer" says why and offers a retry. The composer shows at once, with the
+ attach and the mic.
+- **The Chats tab has an AI chats row** among the DMs, placed by date like the others: the Vibe mark, "AI chats", the
+ count, the last conversation's title and when Vibe answered; tapping it opens the Vibe tab on its list. A group's row
+ shows up to three faces, not a number sign.
+- **The Dock keeps its size and corner per person** and its grip is always visible; the Vibe conversation and the
+ Agents filter are restored with it.
+- **Restyled here (Opus):** a run is a row, not a box: the state as a coloured word (Running blue with a moving bar,
+ Waiting amber, Done green, Failed red), its words on two lines at most, the title opens it, Approve and Retry as filled
+ pills, the rest under a dots button. The tiles lead with their number (11 runs) and dots for done and failed. The Vibe
+ list gives each conversation its mark and its site's name (Blog, not openvibe.blog) and New conversation is a button.
+ The AI chats row has a Vibe avatar like every other row; group faces are ringed in the panel's own colour.
+- The one-tap skills are read through Network (`/api/v1/me/actions/skills`): every site's CSP refuses openvibe.actor.
+- The extension's read-only Dock offers no approve, decline, stop or retry.
+- **Early hints: the Link header only, by default** (`openvibe-shared/sw` `earlyHints`). nginx 1.28, the host's, takes an
+ upstream 103 as the final response and sends the real 200 as its body (tried 11-09 against nginx:1.28-alpine). The 103
+ goes out only with `{ send103: true }` or `OV_EARLY_HINTS=1`, for a proxy that passes it on (nginx 1.29+ with
+ `early_hints`). Cloudflare's Early Hints read the Link header.
+- **The Feed's composer takes pictures** (the owner, 11-09: "you can make posts and put images"). Up to four, each
+ with a line saying what it shows (Community requires the alt text; Post stays off until each has one). Big phone
+ photos are scaled to 2560 px before they go. Each picture uploads through Network (`POST /api/frame/attachments`,
+ Network #719) and the post names them (`POST /api/frame/posts`). Previews are small data: images, because not
+ every site's img-src allows blob:.
+
## 4.167.0 — 2026-11-09
## 4.167.0 — 2026-11-09
- **The Feed, everything on the network, day by day** (the owner: "one huge feed of everything… all kinds of events and
- **The Feed, everything on the network, day by day** (the owner: "one huge feed of everything… all kinds of events and
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.167.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.168.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.167.0",
+ "version": "4.168.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+/* Generated by scripts/build-ai-marks-client.js from ai-marks-data.js. */
+(function (root) {
+ 'use strict';
+ if (!root || root.OpenVibeAIMarks) return;
+ const DATA = {"OpenAI":"<path d=\"M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z\"></path>","Anthropic":"<path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\"></path>","Google":"<path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\"></path>","Gemma":"<path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\"></path>","Meta":"<path d=\"M6.897 4c1.915 0 3.516.932 5.43 3.376l.282-.373c.19-.246.383-.484.58-.71l.313-.35C14.588 4.788 15.792 4 17.225 4c1.273 0 2.469.557 3.491 1.516l.218.213c1.73 1.765 2.917 4.71 3.053 8.026l.011.392.002.25c0 1.501-.28 2.759-.818 3.7l-.14.23-.108.153c-.301.42-.664.758-1.086 1.009l-.265.142-.087.04a3.493 3.493 0 01-.302.118 4.117 4.117 0 01-1.33.208c-.524 0-.996-.067-1.438-.215-.614-.204-1.163-.56-1.726-1.116l-.227-.235c-.753-.812-1.534-1.976-2.493-3.586l-1.43-2.41-.544-.895-1.766 3.13-.343.592C7.597 19.156 6.227 20 4.356 20c-1.21 0-2.205-.42-2.936-1.182l-.168-.184c-.484-.573-.837-1.311-1.043-2.189l-.067-.32a8.69 8.69 0 01-.136-1.288L0 14.468c.002-.745.06-1.49.174-2.23l.1-.573c.298-1.53.828-2.958 1.536-4.157l.209-.34c1.177-1.83 2.789-3.053 4.615-3.16L6.897 4zm-.033 2.615l-.201.01c-.83.083-1.606.673-2.252 1.577l-.138.199-.01.018c-.67 1.017-1.185 2.378-1.456 3.845l-.004.022a12.591 12.591 0 00-.207 2.254l.002.188c.004.18.017.36.04.54l.043.291c.092.503.257.908.486 1.208l.117.137c.303.323.698.492 1.17.492 1.1 0 1.796-.676 3.696-3.641l2.175-3.4.454-.701-.139-.198C9.11 7.3 8.084 6.616 6.864 6.616zm10.196-.552l-.176.007c-.635.048-1.223.359-1.82.933l-.196.198c-.439.462-.887 1.064-1.367 1.807l.266.398c.18.274.362.56.55.858l.293.475 1.396 2.335.695 1.114c.583.926 1.03 1.6 1.408 2.082l.213.262c.282.326.529.54.777.673l.102.05c.227.1.457.138.718.138.176.002.35-.023.518-.073.338-.104.61-.32.813-.637l.095-.163.077-.162c.194-.459.29-1.06.29-1.785l-.006-.449c-.08-2.871-.938-5.372-2.2-6.798l-.176-.189c-.67-.683-1.444-1.074-2.27-1.074z\"></path>","DeepSeek":"<path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\"></path>","Qwen":"<path d=\"M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z\"></path>","Mistral":"<path clip-rule=\"evenodd\" d=\"M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z\"></path>","Kimi":"<path d=\"M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z\"></path><path d=\"M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z\"></path>","GLM":"<path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\"></path>","MiniMax":"<path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\"></path>","Microsoft":"<path d=\"M11.49 2H2v9.492h9.492V2h-.002z\"></path><path d=\"M22 2h-9.492v9.492H22V2z\"></path><path d=\"M11.49 12.508H2V22h9.492v-9.492h-.002z\"></path><path d=\"M22 12.508h-9.492V22H22v-9.492z\"></path>","xAI":"<path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path>","NVIDIA":"<path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\"></path>","Cohere":"<path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\"></path>","Perplexity":"<path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\"></path>"};
+ const ALIAS = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google', gemma: 'Gemma', meta: 'Meta', deepseek: 'DeepSeek', qwen: 'Qwen', mistral: 'Mistral', kimi: 'Kimi', glm: 'GLM', minimax: 'MiniMax', microsoft: 'Microsoft', xai: 'xAI', nvidia: 'NVIDIA', cohere: 'Cohere', perplexity: 'Perplexity' };
+ const mark = (maker, model) => {
+ const text = String(maker || model || '').toLowerCase();
+ const key = Object.keys(ALIAS).find((name) => text.includes(name)) || (/gpt|\bo[134]\b/.test(text) ? 'openai' : /claude/.test(text) ? 'anthropic' : /gemini/.test(text) ? 'google' : /grok/.test(text) ? 'xai' : null);
+ if (!key || !DATA[ALIAS[key]]) return null;
+ const tile = document.createElement('span'); tile.className = 'ovdk-run-maker'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', ALIAS[key]);
+ tile.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + DATA[ALIAS[key]] + '</svg>';
+ return tile;
+ };
+ root.OpenVibeAIMarks = { mark };
+})(typeof window !== 'undefined' ? window : null);
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+/** The Dock's content-aware run list. All content comes from Network's action feed and Actor's skill catalogue. */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockRuns) return;
+ const CSS = `
+.ovdk-overview{display:grid;gap:8px;padding:4px 10px 12px}.ovdk-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
+.ovdk-tile{min-width:0;display:grid;align-content:start;gap:2px;padding:10px 11px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#ececf1) 5%,transparent)}
+.ovdk-tile small{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#8b8b96)}
+.ovdk-tile b{font-size:22px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums;letter-spacing:-.02em}.ovdk-tile b em{font-style:normal;font-size:12px;font-weight:650;color:var(--text-secondary,#a8a8b2);letter-spacing:0;margin-left:4px}
+.ovdk-tile span{white-space:pre-wrap;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--text-secondary,#a8a8b2);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.ovdk-tile .ovdk-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 3px 1px 0;background:var(--ok,#3fb27f)}.ovdk-tile .ovdk-dot.is-bad{background:var(--live-red,#e5484d)}
+.ovdk-tile-next b{font-size:15px;line-height:1.25;letter-spacing:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.ovdk-tile-next span{color:var(--accent-light,var(--accent,#60a5fa));font-weight:700}
+.ovdk-overview h3{font-size:11px;font-weight:750;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted,#8b8b96);margin:8px 2px 0}.ovdk-skills{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
+.ovdk-skills button{min-height:44px;padding:9px 11px;border-radius:12px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);color:inherit;text-align:left;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650}
+.ovdk-skills button:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}.ovdk-overview .ovdk-runs{padding:0}.ovdk-run-filters{display:flex;gap:4px;overflow-x:auto;padding:8px 10px;scrollbar-width:none}.ovdk-run-filters::-webkit-scrollbar{display:none}
+.ovdk-run-filters button{min-height:36px;padding:0 11px;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:99px;background:transparent;color:inherit;cursor:pointer;white-space:nowrap;font:inherit;font-size:12px}
+.ovdk-run-filters button[aria-pressed=true]{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}
+.ovdk-runs{list-style:none;margin:0;padding:0 6px 10px;display:grid;gap:2px}.ovdk-run{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:11px;padding:10px 8px;border-radius:14px;min-width:0}
+.ovdk-run-more{position:relative;align-self:start}.ovdk-run-more summary{list-style:none;width:36px;height:36px;display:grid;place-items:center;border-radius:50%;cursor:pointer;color:var(--text-muted,#8b8b96)}
+.ovdk-run-more summary::-webkit-details-marker{display:none}.ovdk-run-more summary svg{width:18px;height:18px;fill:currentColor}
+@media (hover:hover){.ovdk-run-more summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent);color:var(--text-primary,#ececf1)}}
+.ovdk-run-more menu{position:absolute;right:0;top:38px;z-index:3;min-width:210px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:12px;background:var(--bg-primary,#19191f);box-shadow:0 10px 28px rgba(0,0,0,.28)}
+.ovdk-run-more menu a,.ovdk-run-more menu button{display:flex;align-items:center;width:100%;min-height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:inherit;font:inherit;font-size:13px;text-decoration:none;cursor:pointer;text-align:left}
+.ovdk-run-more menu a:hover,.ovdk-run-more menu button:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}
+@media(max-width:640px){.ovdk-run-more summary{width:44px;height:44px}.ovdk-run-more menu{top:46px}}
+@media (hover:hover){.ovdk-run:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}}
+.ovdk-run-st{font-weight:750;margin-right:4px}.ovdk-run[data-state=running] .ovdk-run-st{color:var(--accent-light,var(--accent,#60a5fa))}.ovdk-run[data-state=waiting] .ovdk-run-st{color:#f0a73a}
+.ovdk-run[data-state=scheduled] .ovdk-run-st{color:var(--text-primary,#ececf1)}.ovdk-run[data-state=done] .ovdk-run-st{color:var(--ok,#3fb27f)}.ovdk-run[data-state=failed] .ovdk-run-st{color:var(--live-red,#e5484d)}
+.ovdk-run-ic{width:34px;height:34px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb,var(--run-color,var(--accent,#3b82f6)) 15%,transparent);color:var(--run-color,var(--accent,#3b82f6))}
+.ovdk-run-sub{min-width:0;display:grid;gap:3px}.ovdk-run-sub>b,.ovdk-run-title b{display:block;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ovdk-run-title{color:inherit;text-decoration:none;min-width:0}.ovdk-run-title:hover b{text-decoration:underline;text-underline-offset:2px}.ovdk-run-line{font-size:12.5px;color:var(--text-secondary,#a8a8b2);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.ovdk-run-model{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--text-secondary,#a8a8b2)}.ovdk-run-maker{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent)}.ovdk-run-maker svg{width:14px;height:14px;fill:currentColor}
+.ovdk-run-bar{height:3px;margin:2px 0;background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent);border-radius:9px;overflow:hidden}.ovdk-run-bar:after{content:'';display:block;width:40%;height:100%;border-radius:9px;background:var(--accent,#3b82f6);animation:ovdk-run-go 1.6s cubic-bezier(.45,0,.2,1) infinite}
+@keyframes ovdk-run-go{from{transform:translateX(-100%)}to{transform:translateX(250%)}}@media (prefers-reduced-motion:reduce){.ovdk-run-bar:after{animation:none;width:45%}}
+.ovdk-run-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:3px}.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary{min-height:32px;display:inline-flex;align-items:center;padding:0 12px;border-radius:999px;color:inherit;background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent);border:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650;text-decoration:none;list-style:none}
+.ovdk-run-actions summary::-webkit-details-marker{display:none}.ovdk-run-actions .primary{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}
+@media (hover:hover){.ovdk-run-actions a:hover,.ovdk-run-actions button:not(.primary):hover,.ovdk-run-actions summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 12%,transparent)}}
+.ovdk-run-actions details{position:relative}.ovdk-run-actions details menu{position:absolute;right:0;z-index:2;min-width:180px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:10px;background:var(--bg-primary,#19191f);box-shadow:0 8px 24px rgba(0,0,0,.2)}
+.ovdk-run-repeat{display:block;width:100%;min-height:36px;padding:0 10px;text-align:left;border:0;border-radius:10px;background:transparent;color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:12.5px;font-weight:650;cursor:pointer}
+@media(max-width:640px){.ovdk-run-filters button,.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary,.ovdk-run-repeat{min-height:44px}.ovdk-skills button{min-height:52px}}
+`;
+ const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
+ const ago = (at) => { const n = Date.parse(at || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.round((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`; };
+ const until = (at) => { const m = Math.max(0, Math.ceil((Date.parse(at || '') - Date.now()) / 60000)); return m < 1 ? 'now' : m < 60 ? `in ${m} min` : m < 1440 ? `in ${Math.ceil(m / 60)} h` : `in ${Math.ceil(m / 1440)} d`; };
+ const cash = (n) => n == null ? '' : Number(n) < .01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`;
+ const iconOf = (it) => { const s = String(it.skill || it.subject && it.subject.kind || it.kind || ''); return /post|blog|write|changelog|draft|patch/.test(s) ? ['fa-pen','#d7974d'] : /wiki|article|research/.test(s) ? ['fa-book','#9875d8'] : /image|pic|meme/.test(s) ? ['fa-image','#d66bad'] : /chat|thread/.test(s) ? ['fa-comments','#61a9d7'] : /code|repo/.test(s) ? ['fa-code','#63b89c'] : /clip|stream|video/.test(s) ? ['fa-film','#dc866a'] : /quest/.test(s) ? ['fa-flag','#b9a74b'] : ['fa-sparkles','#8b92d9']; };
+ const dots = () => { const NS = 'http://www.w3.org/2000/svg'; const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
+ [5.5, 12, 18.5].forEach((x) => { const c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', String(x)); c.setAttribute('cy', '12'); c.setAttribute('r', '1.7'); s.append(c); }); return s; };
+ const isUrl = (u) => /^https:\/\//.test(String(u || ''));
+ // Where a person picks the model a run uses, or adds their own key.
+ const MODEL_PAGE = 'https://openvibe.actor/chat/model';
+ function mount(panel, o) {
+ const st = o.styles && o.styles(); if (st && !st.textContent.includes('.ovdk-overview{')) st.textContent += CSS;
+ // A site's Dock passes no scopes (full behaviour); the extension passes its read-only set.
+ const canDecide = !Array.isArray(o.scopes) || o.scopes.includes('actions.decide');
+ let doc = null, busy = false, timer = 0, filter = o.state().agent_filter || 'all';
+ const request = async (path, init) => o.call(path, init);
+ const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];
+ function row(it) {
+ const li = h('li', 'ovdk-run ovac-item'); li.dataset.state = it.bucket; li.dataset.bucket = it.bucket;
+ const [glyph, color] = iconOf(it); li.style.setProperty('--run-color', color);
+ const ic = h('span', 'ovdk-run-ic'); const i = h('i', `fa-solid ${glyph}`); i.setAttribute('aria-hidden', 'true'); ic.append(i);
+ const body = h('div', 'ovdk-run-sub');
+ const title = it.subject && it.subject.title || it.title || 'Agent run';
+ const url = it.subject && it.subject.url || it.href;
+ if (isUrl(url)) { const t = h('a', 'ovdk-run-title'); t.href = url; t.target = '_blank'; t.rel = 'noopener'; t.append(h('b', '', title)); body.append(t); } else body.append(h('b', '', title));
+ const error = it.run_error; let st = 'Done', line = '';
+ if (it.bucket === 'running') { st = 'Running'; line = it.step || it.summary || 'Working'; }
+ else if (it.bucket === 'waiting') { st = 'Waiting'; line = it.summary || 'Your answer is needed'; }
+ else if (it.bucket === 'scheduled') { st = 'Scheduled'; line = it.next_run_at ? until(it.next_run_at) : 'Time pending'; }
+ else if (it.bucket === 'failed') { st = 'Failed'; line = error ? `${error.words} ${error.next}` : 'This run could not finish. Open it for details.'; }
+ else line = [it.summary, cash(it.cost_usd), it.duration_ms != null ? `${Math.round(it.duration_ms / 1000)}s` : ''].filter(Boolean).join(' · ');
+ const ln = h('span', 'ovdk-run-line'); ln.append(h('span', 'ovdk-run-st', st), document.createTextNode(line)); body.append(ln);
+ if (it.model) { const model = h('span', 'ovdk-run-model'); const mark = root.OpenVibeAIMarks && root.OpenVibeAIMarks.mark(it.maker, it.model); if (mark) model.append(mark); model.append(String(it.model)); body.append(model); }
+ if (it.bucket === 'running') body.append(h('span', 'ovdk-run-bar'));
+ const acts = h('div', 'ovdk-run-actions'); let more = null;
+ if (canDecide && it.bucket === 'waiting' && (it.actions || []).includes('approve')) for (const action of ['approve','decline']) {
+ const b = h('button', action === 'approve' ? 'primary' : '', action === 'approve' ? 'Approve' : 'Decline'); b.type = 'button'; b.dataset.do = action;
+ b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/${action}`, { method: 'POST', body: '{}' }); b.textContent = action === 'approve' ? 'Approved' : 'Declined'; } catch { b.disabled = false; b.textContent = 'Try again'; } }); acts.append(b);
+ }
+
+ // The error's own action says what the person can do about it: pick a model, add a key, or wait.
+ const what = error && error.action;
+ if (it.bucket === 'failed' && (what === 'pick_model' || what === 'add_key')) { const a = h('a', '', what === 'pick_model' ? 'Pick a model' : 'Add a key'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; acts.append(a); }
+ if (canDecide && it.bucket === 'running' && (it.actions || []).includes('cancel')) { const b = h('button', '', 'Stop'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/cancel`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }
+ if (canDecide && it.bucket === 'failed' && (it.actions || []).includes('retry')) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }
+ if (canDecide && it.service === 'actor' && (it.kind === 'agent_task') && ['done', 'failed'].includes(it.bucket)) { const menu = h('details', 'ovdk-run-more'); const summary = h('summary'); summary.setAttribute('aria-label', 'More'); summary.title = 'More'; summary.append(dots()); menu.append(summary); const list = h('menu');
+ const choose = h('li'); const a = h('a', '', 'Run again with another model'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; choose.append(a); list.append(choose);
+ const rerun = h('li'); const b = h('button', '', 'Run again'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/actor/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); menu.open = false; refresh(); } catch { b.disabled = false; b.textContent = 'Try again'; } }); rerun.append(b); list.append(rerun); menu.append(list); more = menu; }
+ if (acts.childNodes.length) body.append(acts); li.append(ic, body); if (more) li.append(more); if (root.OVGlyphs) root.OVGlyphs.swap(li); return li;
+ }
+ function grouped(items) { const map = new Map(); for (const it of items) { const day = String(it.updated_at || it.created_at || '').slice(0,10); const key = [it.skill || it.kind, it.subject && it.subject.kind || '', it.bucket, day].join('|'); if (!map.has(key)) map.set(key, []); map.get(key).push(it); } return [...map.values()]; }
+ function rows(items, target) { const list = h('ul', 'ovdk-runs'); grouped(items).forEach((group) => { if (group.length === 1) list.append(row(group[0])); else { const li = row(group[0]); const b = h('button', 'ovdk-run-repeat', `×${group.length} similar runs · Show all`); const details = h('ul', 'ovdk-runs'); details.hidden = true; group.slice(1).forEach((it) => details.append(row(it))); b.addEventListener('click', () => { details.hidden = !details.hidden; b.textContent = details.hidden ? `×${group.length} similar runs · Show all` : 'Hide repeats'; }); li.querySelector('.ovdk-run-sub').append(b, details); list.append(li); } }); target.append(list); }
+ async function skills(box) {
+ try {
+ // Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).
+ const data = await request('/api/v1/me/actions/skills');
+ const available = (data.listings || []).filter((x) => x && x.skill && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active').slice(0, 6);
+ if (!available.length) return;
+ box.append(h('h3', '', 'Things agents can do for you'));
+ const grid = h('div', 'ovdk-skills');
+ available.forEach((s) => {
+ const b = h('button', '', s.skill.name); b.type = 'button'; b.setAttribute('aria-label', `Start ${s.skill.name}`);
+ b.addEventListener('click', async () => {
+ b.disabled = true;
+ try {
+ const out = await request(`/api/v1/me/actions/skills/${encodeURIComponent(s.id)}/run`, { method: 'POST', body: JSON.stringify({ input: {}, choice: { mode: 'balanced' } }) });
+ if (out.run && out.run.id) root.location.href = `${o.actorBase}/tasks/${encodeURIComponent(out.run.id)}`;
+ else refresh();
+ } catch { b.disabled = false; b.textContent = `${s.skill.name} · Try again`; }
+ });
+ grid.append(b);
+ });
+ box.append(grid);
+ } catch { /* catalogue unavailable */ }
+ }
+ function overview() { const box = h('section', 'ovdk-overview'); const tiles = h('div', 'ovdk-tiles'); const now = Date.now(); const items = all(); const next = (doc.buckets.scheduled || []).find((x) => x.next_run_at); const periods = [[1,'Today'],[7,'This week']];
+ const first = h('div', 'ovdk-tile ovdk-tile-next'); first.append(h('small', '', 'Next'), h('b', '', next ? next.title : 'Nothing scheduled')); if (next) first.append(h('span', '', until(next.next_run_at))); tiles.append(first);
+ periods.forEach(([days,label]) => { const recent = items.filter((x) => now - Date.parse(x.updated_at || x.created_at) < days * 86400000); const done = recent.filter((x) => x.bucket === 'done').length, failed = recent.filter((x) => x.bucket === 'failed').length; const cost = recent.reduce((n, x) => n + (Number(x.cost_usd) || 0), 0);
+ const tile = h('div', 'ovdk-tile'); const n = h('b', '', String(recent.length)); n.append(h('em', '', recent.length === 1 ? 'run' : 'runs')); const sub = h('span');
+ if (done) { sub.append(h('i', 'ovdk-dot'), document.createTextNode(`${done} `)); } if (failed) { sub.append(h('i', 'ovdk-dot is-bad'), document.createTextNode(`${failed} `)); } if (cost) sub.append(document.createTextNode(cash(cost))); if (!recent.length) sub.textContent = 'None yet';
+ tile.setAttribute('aria-label', `${label}: ${recent.length} runs, ${done} done, ${failed} failed${cost ? `, ${cash(cost)}` : ''}`); sub.title = `${done} done · ${failed} failed`;
+ tile.append(h('small', '', label), n, sub); tiles.append(tile); }); box.append(tiles);
+ const recent = [...(doc.buckets.done || []), ...(doc.buckets.failed || [])].sort((a,b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0,3); if (recent.length) { box.append(h('h3', '', 'Last results')); rows(recent, box); }
+ skills(box); return box; }
+ function paint() { if (!doc) return; panel.textContent = ''; const items = all(); const filters = h('div', 'ovdk-run-filters'); for (const [id,label] of [['all','All'],['running','Running'],['waiting','Waiting'],['scheduled','Scheduled'],['done','Done'],['failed','Failed']]) { const b = h('button', '', label); b.type='button'; b.setAttribute('aria-pressed', String(filter===id)); b.addEventListener('click', () => { filter=id; o.save({agent_filter:id}); paint(); }); filters.append(b); } panel.append(filters);
+ if (filter==='all' && !(doc.counts.running || doc.counts.waiting)) panel.append(overview());
+ else { const shown = filter==='all' ? items : (doc.buckets[filter] || []); if (shown.length) rows(shown,panel); else panel.append(h('p','ovdk-none','No runs in this view.')); }
+ const foot = o.foot || h('div','ovdk-foot'); foot.textContent=''; const allLink = h('a','','All runs'); allLink.href='https://openvibe.actor/tasks'; const give = h('a','','Give an agent a task'); give.href='https://openvibe.actor/skills'; foot.append(allLink,give); if (!o.foot) panel.append(foot); }
+ async function refresh() { if (busy || !o.active()) return; busy=true; clearTimeout(timer); try { doc = await request('/api/v1/me/actions'); if (doc && doc.buckets) { paint(); if (o.onCounts) o.onCounts(doc.counts || {}); } } catch { panel.textContent=''; panel.append(h('div','ovdk-none','Your runs did not load. Open Agents again to retry.')); } finally { busy=false; if (o.active() && doc && doc.counts && doc.counts.running) timer=setTimeout(refresh,15000); } }
+ refresh(); if (o.loadMarks) o.loadMarks().then((ok) => { if (ok && doc) paint(); });
+ return { refresh, destroy: () => clearTimeout(timer) };
+ }
+ root.OpenVibeDockRuns = { mount };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -367,11 +367,11 @@
@@ -367,11 +367,11 @@
let data;
let data;
try { data = await api(path); } catch (err) {
try { data = await api(path); } catch (err) {
if (destroyed) return;
if (destroyed) return;
- nowBox.textContent = '';
- nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'The agent is not reachable right now.'));
+ container.hidden = true;
return;
return;
}
}
if (destroyed) return;
if (destroyed) return;
+ container.hidden = !data.now && !(data.needs_you || []).length && !(data.entries || []).length;
renderNow(data.now);
renderNow(data.now);
renderNeeds(data.needs_you || []);
renderNeeds(data.needs_you || []);
renderChips(data.counts || {});
renderChips(data.counts || {});
@@ -12,6 +12,18 @@
@@ -12,6 +12,18 @@
const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';
const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';
const CSS = `
const CSS = `
.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
+ .ovdk-vibe-list { list-style: none; padding: 6px; margin: 0; display: grid; gap: 3px; }
+ .ovdk-vibe-list button { width: 100%; min-height: 60px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }
+ .ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }
+ .ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
+ .ovdk-vibe-list small { justify-self: end; }
+ .ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }
+ .ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }
+ .ovdk-vibe-list .ovdk-vibe-new { display: flex; gap: 8px; justify-content: center; min-height: 44px; margin: 2px 0 6px; border-radius: 999px; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); font-weight: 750; font-size: 14px; }
+ .ovdk-vibe-list .ovdk-vibe-new:hover, .ovdk-vibe-list .ovdk-vibe-new:focus-visible { background: var(--accent-strong, var(--accent, #2563eb)); }
+ .ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
.ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }
.ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }
.ovdk-away:hover { text-decoration: underline; }
.ovdk-away:hover { text-decoration: underline; }
.ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }
.ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }
@@ -23,7 +35,8 @@
@@ -23,7 +35,8 @@
.ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
.ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
.ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
.ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
.ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
.ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
- .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
+ .ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
+ .ovdk-attach:hover { color: var(--text-primary,#ececf1); }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
.ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
.ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
.ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }
.ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }
@@ -54,7 +67,7 @@
@@ -54,7 +67,7 @@
// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
// (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
// (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
// Everything is set as text; a link is a link only when it is https.
// Everything is set as text; a link is a link only when it is https.
- const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
+ const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const TALK = Boolean(SR || MR);
const TALK = Boolean(SR || MR);
@@ -115,15 +128,48 @@
@@ -115,15 +128,48 @@
if (m.at) li.title = new Date(m.at).toLocaleString();
if (m.at) li.title = new Date(m.at).toLocaleString();
return li;
return li;
}
}
+ const age = (iso) => { const n = Date.parse(iso || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.floor((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };
+ async function openList() {
+ clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
+ const panel = vibe.panel; if (!panel) return;
+ panel.textContent = '';
+ const head = h('div', 'ovdk-thread-h ovdk-vibe-h'); head.append(icon(G.vibe), h('b', '', 'Vibe conversations'));
+ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener'; head.append(away);
+ const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');
+ const newer = h('button', 'ovdk-vibe-new', 'New conversation'); newer.type = 'button'; newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
+ const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);
+ try {
+ const out = await ctx.chatCall('/api/vibe/conversations');
+ if (vibe.view !== 'list' || vibe.panel !== panel) return;
+ const conversations = Array.isArray(out.conversations) ? out.conversations : [];
+ if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));
+ conversations.forEach((c) => {
+ const li = h('li'), b = h('button'); b.type = 'button';
+ const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));
+ const line = h('span', '', c.last_line || 'No messages yet');
+ // The site it began on, by name ("Blog", not openvibe.blog), beside the time.
+ const host = String(c.site || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ const name = host ? (/^openvibe\.([a-z]+)$/.exec(host) || [])[1] : '';
+ const site = h('small', '', name ? name.charAt(0).toUpperCase() + name.slice(1) : host);
+ const mark = h('span', 'ovdk-vibe-site'); mark.append(icon(G.vibe));
+ b.append(mark, title, time, line, site);
+ b.setAttribute('aria-label', `${c.name || 'Conversation'}, ${age(c.last_message_at)}`);
+ b.addEventListener('click', () => { vibe.fresh = false; vibe.chat = { slug: c.slug }; ctx.save({ vibe: { slug: c.slug, at: new Date().toISOString() } }); openVibe(); });
+ li.append(b); list.append(li);
+ });
+ } catch (e) { list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); }
+ }
async function openVibe() {
async function openVibe() {
clearTimeout(vibe.timer);
clearTimeout(vibe.timer);
+ vibe.view = 'conversation';
const panel = vibe.panel; if (!panel) return;
const panel = vibe.panel; if (!panel) return;
vibe.msgs = []; vibe.last = 0;
vibe.msgs = []; vibe.last = 0;
panel.textContent = '';
panel.textContent = '';
const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
const title = h('b', '', 'Vibe');
const title = h('b', '', 'Vibe');
const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';
const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';
- head.append(icon(G.vibe), title, away);
+ const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to Vibe conversations'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });
+ head.append(back, icon(G.vibe), title, away);
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
@@ -131,6 +177,29 @@
@@ -131,6 +177,29 @@
const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
+ const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'
+ : e && e.status === 429 ? 'Too many requests were sent. Wait a minute, then retry.'
+ : e && e.code === 'vibe.off' ? 'Vibe is unavailable right now. Retry when it returns.'
+ : e && e.status === 503 ? 'The AI service is unavailable right now. Retry shortly.'
+ : 'The request could not reach Vibe. Check your connection and retry.';
+ const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;
+ if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }
+ };
+ const attach = h('button', 'ovdk-attach', '+'); attach.type = 'button'; attach.setAttribute('aria-label', 'Attach a picture'); attach.title = 'Attach a picture';
+ const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;
+ attach.addEventListener('click', () => file.click());
+ file.addEventListener('change', async () => {
+ const picture = file.files && file.files[0]; if (!picture) return;
+ attach.disabled = true; err.hidden = true;
+ try {
+ const path = vibe.chat ? `/api/chat/rooms/${encodeURIComponent(vibe.chat.slug)}/pictures` : '/api/chat/vibe/picture';
+ const out = await ctx.chatCall(path, { method: 'POST', body: picture, type: picture.type || 'image/jpeg' });
+ if (out.room && out.room.slug) { vibe.chat = { slug: out.room.slug }; vibe.fresh = false; ctx.save({ vibe: { slug: out.room.slug, at: new Date().toISOString() } }); }
+ openVibe();
+ } catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }
+ finally { attach.disabled = false; file.value = ''; }
+ });
+ form.append(attach, file);
let mic = null;
let mic = null;
if (TALK) {
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
@@ -150,8 +219,8 @@
@@ -150,8 +219,8 @@
vibe.busy = true; go.disabled = true; err.hidden = true;
vibe.busy = true; go.disabled = true; err.hidden = true;
try {
try {
let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
- const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });
- if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }
+ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, fresh: vibe.fresh, timezone }) });
+ if (out.chat) { vibe.chat = out.chat; vibe.fresh = false; away.href = out.chat.url; ctx.save({ vibe: { slug: out.chat.slug, at: new Date().toISOString() } }); }
box.value = ''; fit();
box.value = ''; fit();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }
if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }
@@ -160,8 +229,7 @@
@@ -160,8 +229,7 @@
scroll.scrollTop = scroll.scrollHeight;
scroll.scrollTop = scroll.scrollHeight;
clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);
clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);
} catch (x) {
} catch (x) {
- err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';
- err.hidden = false;
+ showFailure(x, () => send(text));
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
@@ -169,13 +237,13 @@
@@ -169,13 +237,13 @@
async function pull(first) {
async function pull(first) {
let got;
let got;
try {
try {
- got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
+ got = vibe.fresh && !vibe.chat ? { chat: null, messages: [] } : await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
} catch (e) {
} catch (e) {
- if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }
+ if (first) { list.textContent = ''; showFailure(e, () => pull(true)); }
return;
return;
}
}
if (!ctx.active() || vibe.panel !== panel) return;
if (!ctx.active() || vibe.panel !== panel) return;
- if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }
+ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; }
if (first) list.textContent = '';
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
let answered = false;
let answered = false;
@@ -186,7 +254,13 @@
@@ -186,7 +254,13 @@
if (m.from === 'vibe') answered = true;
if (m.from === 'vibe') answered = true;
if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();
if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();
});
});
- if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
+ if (answered || Date.now() > vibe.waitUntil) {
+ typing.remove();
+ if (!answered && vibe.waitUntil && Date.now() > vibe.waitUntil) {
+ showFailure({ status: 503 }, vibe.chat ? async () => { await ctx.chatCall(`/api/chat/vibe/${encodeURIComponent(vibe.chat.slug)}/retry`, { method: 'POST', body: '{}' }); err.hidden = true; vibe.waitUntil = Date.now() + 90000; list.append(typing); pull(); } : null);
+ }
+ vibe.waitUntil = 0;
+ }
if (first && !vibe.msgs.length) {
if (first && !vibe.msgs.length) {
const start = h('li', 'ovdk-vibe-start');
const start = h('li', 'ovdk-vibe-start');
start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
@@ -281,13 +355,18 @@
@@ -281,13 +355,18 @@
if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
vibe.panel = panel;
vibe.panel = panel;
- openVibe();
+ // The Chats tab's AI chats row asks for the list, not for the last conversation.
+ const onList = ctx.wantList ? ctx.wantList() : false;
+ const selected = ctx.state && ctx.state().vibe;
+ if (!onList && selected && selected.slug && Date.now() - Date.parse(selected.at) < 20 * 60000) { vibe.chat = { slug: selected.slug }; openVibe(); }
+ else openList();
return {
return {
- refresh: () => { vibe.panel = panel; return openVibe(); },
+ refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); },
+ list: () => { vibe.panel = panel; return openList(); },
pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
talk: () => { if (vibe.talk) vibe.talk(); },
talk: () => { if (vibe.talk) vibe.talk(); },
// A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
// A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
- ask: (text) => { if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
+ ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
};
};
}
}
root.OpenVibeDockVibe = { mount };
root.OpenVibeDockVibe = { mount };
@@ -106,7 +106,7 @@
@@ -106,7 +106,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '40a5f8e3970b', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
+ const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': '3cac6118d6e9', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -222,7 +222,7 @@
@@ -222,7 +222,7 @@
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }
.ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }
.ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }
.ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }
- .ovdk:hover .ovdk-size::after, .ovdk-size:active::after { opacity: .75; }
+ .ovdk .ovdk-size::after { opacity: .75; }
.ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }
.ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }
.ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }
.ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }
.ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }
.ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }
@@ -249,6 +249,11 @@
@@ -249,6 +249,11 @@
.ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;
.ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;
color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
.ovdk-av img { width: 100%; height: 100%; object-fit: cover; }
.ovdk-av img { width: 100%; height: 100%; object-fit: cover; }
+ .ovdk-av-vibe { color: #fff; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent, #3b82f6) 70%, #a78bfa), var(--accent-strong, var(--accent, #2563eb))); }
+ .ovdk-av-vibe svg { width: 22px; height: 22px; }
+ .ovdk-av-group { display: flex; align-items: center; overflow: visible; background: transparent; }
+ .ovdk-av-group .ovdk-av { width: 27px; height: 27px; margin-left: -12px; border: 2px solid var(--bg-elevated, var(--bg-secondary, #1d1f27)); font-size: 11px; }
+ .ovdk-av-group .ovdk-av:first-child { margin-left: 0; }
.ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }
.ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }
.ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -272,7 +277,7 @@
@@ -272,7 +277,7 @@
border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }
border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }
.ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }
.ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }
.ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }
.ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }
- .ovdk-go { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
+ .ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
.ovdk-go:disabled { opacity: .55; cursor: default; }
.ovdk-go:disabled { opacity: .55; cursor: default; }
.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
@@ -525,7 +530,7 @@
@@ -525,7 +530,7 @@
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
+ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
return;
}
}
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
@@ -534,10 +539,10 @@
@@ -534,10 +539,10 @@
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
if (id === 'agent') {
if (id === 'agent') {
- const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
+ const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount));
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
- panel.innerHTML = `<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-runs-foot ovdk-foot"></div>';
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// phone Now and Needs you are on screen before anything else.
// phone Now and Needs you are on screen before anything else.
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
@@ -550,16 +555,13 @@
@@ -550,16 +555,13 @@
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
});
});
- const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {
- token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
+ const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
+ call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
+ styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),
+ foot: panel.querySelector('.ovdk-runs-foot'),
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
});
mounts[id] = m;
mounts[id] = m;
- panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
- const anchor = e.currentTarget;
- close();
- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
- });
return;
return;
}
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
@@ -579,7 +581,7 @@
@@ -579,7 +581,7 @@
// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are
// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are
// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls
// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls
// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.
// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.
- const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null };
+ const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null, vibeSummary: null };
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const me = () => String(o.me || '');
const me = () => String(o.me || '');
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
@@ -592,7 +594,7 @@
@@ -592,7 +594,7 @@
const { type: _type, ...rest } = init;
const { type: _type, ...rest } = init;
return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {
return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
- if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; e.code = b.code || null; throw e; }
return b;
return b;
});
});
}
}
@@ -606,6 +608,8 @@
@@ -606,6 +608,8 @@
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;
return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;
};
};
+ // "answered 4 min ago", and "answered just now" when it was this minute.
+ const answered = (iso) => { const w = when(iso); return !w ? '' : w === 'now' ? 'answered just now' : `answered ${w} ago`; };
function avatar(p, group) {
function avatar(p, group) {
const a = h('span', 'ovdk-av');
const a = h('span', 'ovdk-av');
const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
@@ -613,6 +617,13 @@
@@ -613,6 +617,13 @@
else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
return a;
return a;
}
}
+ function groupAvatar(c) {
+ const people = others(c).slice(0, 3);
+ if (!people.length) return avatar({}, false);
+ const stack = h('span', 'ovdk-av ovdk-av-group');
+ people.forEach((p) => stack.append(avatar(p, false)));
+ return stack;
+ }
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
function chatFoot(panel) {
function chatFoot(panel) {
@@ -627,19 +638,33 @@
@@ -627,19 +638,33 @@
const panel = chats.panel; if (!panel) return;
const panel = chats.panel; if (!panel) return;
if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;
const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;
- try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
+ try {
+ const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);
+ chats.list = dm.conversations || [];
+ chats.vibeSummary = ai && ai.count ? { kind: 'vibe', count: ai.count, latest: ai.conversations && ai.conversations[0], last_message_at: ai.conversations && ai.conversations[0] && ai.conversations[0].last_message_at } : null;
+ } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
if (sharing) await sharing;
if (sharing) await sharing;
if (chats.open || tab !== 'chats') return;
if (chats.open || tab !== 'chats') return;
panel.textContent = '';
panel.textContent = '';
if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
const scroll = h('div', 'ovdk-scroll');
const scroll = h('div', 'ovdk-scroll');
- if (!chats.list.length) {
+ if (!chats.list.length && !chats.vibeSummary) {
const none = h('div', 'ovdk-none');
const none = h('div', 'ovdk-none');
none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));
none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));
scroll.append(none);
scroll.append(none);
} else {
} else {
const ul = h('ul', 'ovdk-chats');
const ul = h('ul', 'ovdk-chats');
- chats.list.forEach((c) => {
+ [...chats.list, ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {
+ if (c.kind === 'vibe') {
+ const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';
+ const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'AI chats'), h('span', 'ovdk-chat-p', `${c.count} conversation${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));
+ const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
+ const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
+ const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
+ b.append(av, body, meta); b.setAttribute('aria-label', `AI chats, ${c.count} conversations${at ? `, ${answered(at)}` : ''}`);
+ b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });
+ li.append(b); ul.append(li); return;
+ }
const li = h('li'), b = h('button', 'ovdk-chat');
const li = h('li'), b = h('button', 'ovdk-chat');
b.type = 'button';
b.type = 'button';
const unread = Number(c.unread_count) || 0;
const unread = Number(c.unread_count) || 0;
@@ -649,7 +674,7 @@
@@ -649,7 +674,7 @@
const meta = h('span', 'ovdk-chat-m');
const meta = h('span', 'ovdk-chat-m');
meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
- b.append(avatar(others(c)[0] || {}, !!c.is_group), body, meta);
+ b.append(c.is_group ? groupAvatar(c) : avatar(others(c)[0] || {}, false), body, meta);
b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);
b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);
if (unread) li.className = 'is-unread';
if (unread) li.className = 'is-unread';
b.addEventListener('click', () => openChat(String(c.id), true));
b.addEventListener('click', () => openChat(String(c.id), true));
@@ -745,6 +770,8 @@
@@ -745,6 +770,8 @@
// every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from
// every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from
// any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).
// any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).
const vibe = { panel: null, api: null };
const vibe = { panel: null, api: null };
+ // The Chats tab's AI chats row opens the Vibe tab on its list, not on the last conversation.
+ let vibeOnList = false;
const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);
const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
@@ -22,6 +22,8 @@ const BROWSER = Object.freeze([
@@ -22,6 +22,8 @@ const BROWSER = Object.freeze([
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
+ 'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
+ 'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '6435ab824d71', 'nav-slots.js': 'a5af310d62e0' };
+ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -122,12 +122,28 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
@@ -122,12 +122,28 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
-.ovh-compose { display: flex; gap: 10px; align-items: flex-start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
+.ovh-compose { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }
background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }
.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }
.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }
.ovh-post:disabled { opacity: .5; cursor: default; }
.ovh-post:disabled { opacity: .5; cursor: default; }
+.ovh-compose-main { display: grid; gap: 8px; min-width: 0; }
+.ovh-compose-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ovh-compose-bar .ovh-post { margin-left: auto; }
+.ovh-compose-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovh-pic-btn { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 12px; background: transparent; color: var(--accent-light, var(--accent, #60a5fa)); cursor: pointer; }
+.ovh-pic-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+.ovh-pic-btn:disabled { opacity: .4; cursor: default; background: transparent; }
+.ovh-pic-btn svg, .ovh-pic-x svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-pics { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.ovh-pics[hidden] { display: none; }
+.ovh-pic { display: grid; grid-template-columns: 56px minmax(0, 1fr) 40px; gap: 8px; align-items: center; }
+.ovh-pic img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
+.ovh-pic input { min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }
+.ovh-pic-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
+.ovh-pic-x svg { width: 18px; height: 18px; }
+.ovh-pic-x:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); }
.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
@@ -287,6 +303,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -287,6 +303,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],
gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],
x: [['path', { d: 'M6 6l12 12M18 6 6 18' }]],
x: [['path', { d: 'M6 6l12 12M18 6 6 18' }]],
+ image: [['rect', { x: 3.5, y: 4.5, width: 17, height: 15, rx: 3 }], ['circle', { cx: 9, cy: 10, r: 1.8 }], ['path', { d: 'M4 17.5l4.6-4.4a1.5 1.5 0 0 1 2 0l2.4 2.3 2.2-2a1.5 1.5 0 0 1 2 0l3.3 3' }]],
eye: [['path', { d: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z' }], ['circle', { cx: 12, cy: 12, r: 2.8 }]],
eye: [['path', { d: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z' }], ['circle', { cx: 12, cy: 12, r: 2.8 }]],
heart: [['path', { d: 'M12 20s-7.2-4.4-8.9-8.8C1.9 7.9 4 4.8 7.2 4.8c1.9 0 3.4 1 4.8 2.9 1.4-1.9 2.9-2.9 4.8-2.9 3.2 0 5.3 3.1 4.1 6.4C19.2 15.6 12 20 12 20Z' }]],
heart: [['path', { d: 'M12 20s-7.2-4.4-8.9-8.8C1.9 7.9 4 4.8 7.2 4.8c1.9 0 3.4 1 4.8 2.9 1.4-1.9 2.9-2.9 4.8-2.9 3.2 0 5.3 3.1 4.1 6.4C19.2 15.6 12 20 12 20Z' }]],
reply: [['path', { d: 'M20.5 11.6a8 8 0 0 1-11.7 7.1L4 20l1.3-4.4a8 8 0 1 1 15.2-4Z' }]],
reply: [['path', { d: 'M20.5 11.6a8 8 0 0 1-11.7 7.1L4 20l1.3-4.4a8 8 0 1 1 15.2-4Z' }]],
@@ -734,28 +751,101 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -734,28 +751,101 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } })
fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});
.then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});
}
}
+ // A picture for a post: scaled to at most 2560 px on its long side (phone photos are often 12 MB; Community takes 8 MB),
+ // and a small preview drawn to a data: address (every site's img-src allows data:, not all allow blob:).
+ async function preparePicture(file) {
+ const out = { file, thumb: '' };
+ try {
+ const bmp = await createImageBitmap(file);
+ const draw = (w, hgt) => { const c = document.createElement('canvas'); c.width = w; c.height = hgt; c.getContext('2d').drawImage(bmp, 0, 0, w, hgt); return c; };
+ const t = 112 / Math.max(bmp.width, bmp.height);
+ out.thumb = draw(Math.max(1, Math.round(bmp.width * t)), Math.max(1, Math.round(bmp.height * t))).toDataURL('image/jpeg', 0.8);
+ const k = Math.min(1, 2560 / Math.max(bmp.width, bmp.height));
+ if ((k < 1 || file.size > 7.5e6) && !/gif/i.test(file.type)) {
+ const blob = await new Promise((ok) => draw(Math.round(bmp.width * k), Math.round(bmp.height * k)).toBlob(ok, 'image/jpeg', 0.86));
+ if (blob) out.file = new File([blob], `${String(file.name || 'picture').replace(/\.[a-z0-9]+$/i, '')}.jpg`, { type: 'image/jpeg' });
+ }
+ } catch { /* the original goes as it is */ }
+ return out;
+ }
function composer(box, o) {
function composer(box, o) {
const form = h('form', 'ovh-compose');
const form = h('form', 'ovh-compose');
const face = h('span', 'ovh-face');
const face = h('span', 'ovh-face');
const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';
const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';
if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();
if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
+ const main = h('div', 'ovh-compose-main');
const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
+ // Pictures (owner, 11-09: "you can make posts and put images"): up to four, each with a line saying what it shows,
+ // which Community requires (alt text, for people who cannot see it).
+ const pics = []; const list = h('ul', 'ovh-pics'); list.hidden = true;
+ const bar = h('div', 'ovh-compose-bar');
+ const add = h('button', 'ovh-pic-btn'); add.type = 'button'; add.setAttribute('aria-label', 'Add pictures'); add.title = 'Add pictures (up to 4)'; add.append(svgIcon('image'));
+ const file = h('input'); file.type = 'file'; file.accept = 'image/jpeg,image/png,image/webp,image/gif'; file.multiple = true; file.hidden = true;
+ const hint = h('small', 'ovh-compose-hint');
const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;
const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;
- ta.addEventListener('input', () => { post.disabled = !ta.value.trim(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });
- form.append(face, ta, post);
+ const ready = () => {
+ const missing = pics.some((p) => !p.alt.value.trim());
+ post.disabled = !ta.value.trim() || missing;
+ hint.textContent = missing ? 'Describe each picture' : '';
+ add.disabled = pics.length >= 4;
+ list.hidden = !pics.length;
+ };
+ ta.addEventListener('input', () => { ready(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });
+ add.addEventListener('click', () => file.click());
+ file.addEventListener('change', async () => {
+ const chosen = [...(file.files || [])].slice(0, 4 - pics.length); file.value = '';
+ for (const f of chosen) {
+ if (!/^image\//.test(f.type)) continue;
+ const pr = await preparePicture(f);
+ if (pr.file.size > 8 * 1024 * 1024) { note.hidden = false; note.textContent = `${f.name} is over 8 MB.`; continue; }
+ const li = h('li', 'ovh-pic');
+ const im = h('img'); im.alt = ''; if (pr.thumb) im.src = pr.thumb;
+ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?'; alt.setAttribute('aria-label', `What ${f.name} shows`);
+ const x = h('button', 'ovh-pic-x'); x.type = 'button'; x.setAttribute('aria-label', 'Remove this picture'); x.title = 'Remove'; x.append(svgIcon('x'));
+ const entry = { file: pr.file, alt, li };
+ x.addEventListener('click', () => { pics.splice(pics.indexOf(entry), 1); li.remove(); ready(); });
+ alt.addEventListener('input', ready);
+ li.append(im, alt, x); list.append(li); pics.push(entry);
+ }
+ ready();
+ const empty = pics.find((p) => !p.alt.value.trim()); if (empty) empty.alt.focus();
+ });
+ bar.append(add, file, hint, post);
+ main.append(ta, list, bar);
+ form.append(face, main);
const note = h('p', 'ovh-note'); note.hidden = true;
const note = h('p', 'ovh-note'); note.hidden = true;
+ const auth = { Authorization: `Bearer ${o.token}` };
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
- const text = ta.value.trim(); if (!text) return;
- post.disabled = true;
+ const text = ta.value.trim(); if (!text || post.disabled) return;
+ post.disabled = true; add.disabled = true; note.hidden = true;
+ const label = post.textContent;
try {
try {
- const r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
+ let r;
+ if (pics.length) {
+ const named = [];
+ for (let k = 0; k < pics.length; k++) {
+ post.textContent = pics.length > 1 ? `Picture ${k + 1} of ${pics.length}` : 'Sending the picture';
+ const fd = new FormData(); fd.append('file', pics[k].file, pics[k].file.name || 'picture.jpg');
+ const up = await fetch(`${o.frameBase || FRAME}/attachments`, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
+ const uj = await up.json().catch(() => ({}));
+ const id = uj && uj.attachment && uj.attachment.media_id;
+ if (!up.ok || !id) throw new Error(uj.detail || 'A picture did not upload. Try again in a moment.');
+ named.push({ media_id: id, alt: pics[k].alt.value.trim() });
+ }
+ post.textContent = 'Posting';
+ r = await fetch(`${o.frameBase || FRAME}/posts`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text, pictures: named }) });
+ } else {
+ r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text }) });
+ }
const j = await r.json().catch(() => ({}));
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
- ta.value = ''; note.hidden = false; note.textContent = 'Posted. It is on your profile and in your followers’ feeds.';
+ ta.value = ''; ta.style.height = ''; pics.splice(0).forEach((p) => p.li.remove());
+ note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';
if (o.onPosted) o.onPosted(j);
if (o.onPosted) o.onPosted(j);
- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); post.disabled = false; }
+ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); }
+ post.textContent = label; ready();
});
});
box.append(form, note);
box.append(form, note);
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.167.0",
+ "version": "4.168.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.167.0",
+ "version": "4.168.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.167.0",
+ "version": "4.168.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,6 +51,8 @@
@@ -51,6 +51,8 @@
"./nav-slots": "./nav-slots.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
+ "./dock-runs": "./dock-runs.js",
+ "./ai-marks-client": "./ai-marks-client.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
"./dock-share": "./dock-share.js",
"./dock-card": "./dock-card.js",
"./dock-card": "./dock-card.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+'use strict';
+// A lazy browser copy of ai-marks-data for the Dock's model receipts. Source and attribution stay in ai-marks-data.js.
+const fs = require('fs');
+const path = require('path');
+const data = require('../ai-marks-data');
+const src = `/* Generated by scripts/build-ai-marks-client.js from ai-marks-data.js. */
+(function (root) {
+ 'use strict';
+ if (!root || root.OpenVibeAIMarks) return;
+ const DATA = ${JSON.stringify(data)};
+ const ALIAS = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google', gemma: 'Gemma', meta: 'Meta', deepseek: 'DeepSeek', qwen: 'Qwen', mistral: 'Mistral', kimi: 'Kimi', glm: 'GLM', minimax: 'MiniMax', microsoft: 'Microsoft', xai: 'xAI', nvidia: 'NVIDIA', cohere: 'Cohere', perplexity: 'Perplexity' };
+ const mark = (maker, model) => {
+ const text = String(maker || model || '').toLowerCase();
+ const key = Object.keys(ALIAS).find((name) => text.includes(name)) || (/gpt|\\bo[134]\\b/.test(text) ? 'openai' : /claude/.test(text) ? 'anthropic' : /gemini/.test(text) ? 'google' : /grok/.test(text) ? 'xai' : null);
+ if (!key || !DATA[ALIAS[key]]) return null;
+ const tile = document.createElement('span'); tile.className = 'ovdk-run-maker'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', ALIAS[key]);
+ tile.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + DATA[ALIAS[key]] + '</svg>';
+ return tile;
+ };
+ root.OpenVibeAIMarks = { mark };
+})(typeof window !== 'undefined' ? window : null);
+`;
+fs.writeFileSync(path.join(__dirname, '..', 'ai-marks-client.js'), src);
@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js'];
'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
-const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
+const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
@@ -72,14 +72,16 @@ function linkHeader(link) {
@@ -72,14 +72,16 @@ function linkHeader(link) {
*
*
* sw.earlyHints(res, [ { href: '/shared/navbar.js?v=abc123', as: 'script' } ]);
* sw.earlyHints(res, [ { href: '/shared/navbar.js?v=abc123', as: 'script' } ]);
*
*
- * The 103 goes out first when the runtime can send one (Node 18.11 and later); the Link header rides on the page itself so
- * the hint survives a proxy that drops the 103. Returns true when a 103 was sent.
+ * The Link header rides on the page. The 103 itself goes out only where the proxy in front passes it on: `{ send103: true }`
+ * or OV_EARLY_HINTS=1 (nginx 1.29+ with `early_hints`). nginx 1.28 (the host's, 11-09) takes an upstream 103 as the final
+ * response and sends the real 200 as its body, so the default is the header only; Cloudflare's Early Hints read it.
+ * Returns true when a 103 was sent.
*/
*/
-function earlyHints(res, links) {
+function earlyHints(res, links, { send103 = process.env.OV_EARLY_HINTS === '1' } = {}) {
const list = (Array.isArray(links) ? links : []).filter((l) => l && l.href);
const list = (Array.isArray(links) ? links : []).filter((l) => l && l.href);
if (!list.length || !res) return false;
if (!list.length || !res) return false;
let sent = false;
let sent = false;
- if (typeof res.writeEarlyHints === 'function') {
+ if (send103 && typeof res.writeEarlyHints === 'function') {
try {
try {
res.writeEarlyHints({ link: list.map(linkHeader).filter(Boolean) });
res.writeEarlyHints({ link: list.map(linkHeader).filter(Boolean) });
sent = true;
sent = true;
@@ -46,6 +46,49 @@ const ACTIONS = {
@@ -46,6 +46,49 @@ const ACTIONS = {
scheduled: [], done: [], failed: [],
scheduled: [], done: [], failed: [],
},
},
};
};
+// The Dock v2's rows (plan 2026-11-09 §8): a skill, a subject, a model, a maker, a cost, a duration, a live step and,
+// for a failure, safe words and the next step. Six identical failures in a day are one row.
+const DOCK_ACTIONS = {
+ generated_at: iso(2000), sources: { actor: 'ok', codes: 'ok' },
+ counts: { running: 1, waiting: 1, scheduled: 1, done: 2, failed: 6 },
+ buckets: {
+ waiting: [item({ id: 'apr_1', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'waiting', state: 'waiting',
+ skill: 'blog.write', subject: { kind: 'post', title: 'Patch notes draft', url: 'https://openvibe.blog/posts/42' },
+ title: 'Patch notes draft', summary: 'It needs the release name to finish the notes.', href: 'https://openvibe.actor/tasks/t3',
+ actions: ['approve', 'decline'] })],
+ running: [item({ id: 'tsk_1', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'running', state: 'running',
+ skill: 'changelog-draft', subject: { kind: 'post', title: 'Patch notes draft · OpenVibe blog', url: 'https://openvibe.blog/posts/42' },
+ title: 'Patch notes draft · OpenVibe blog', step: 'Writing the release notes…', model: 'gpt-5-nano', maker: 'OpenAI',
+ duration_ms: 42000, href: 'https://openvibe.actor/tasks/tsk_1', steps: 14 })],
+ scheduled: [item({ id: 'tsk_4', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'scheduled', state: 'scheduled',
+ skill: 'wiki-article', subject: { kind: 'wiki', title: 'The Dock, for beginners', url: 'https://openvibe.wiki/a/dock' },
+ title: 'Make a wiki article', next_run_at: new Date(Date.now() + 42 * 60e3).toISOString(), href: 'https://openvibe.actor/tasks/tsk_4' })],
+ done: [
+ item({ id: 'tsk_5', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'done', state: 'succeeded',
+ skill: 'changelog-draft', subject: { kind: 'post', title: '4.162.0 release notes', url: 'https://openvibe.blog/posts/39' },
+ title: '4.162.0 release notes', summary: 'The draft is ready for review.', cost_usd: 0.0031, duration_ms: 68000,
+ model: 'gpt-5-nano', maker: 'OpenAI', href: 'https://openvibe.blog/posts/39' }),
+ item({ id: 'tsk_6', service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'done', state: 'succeeded',
+ skill: 'wiki-article', subject: { kind: 'wiki', title: 'What is a Dock?', url: 'https://openvibe.wiki/a/dock' },
+ title: 'What is a Dock?', summary: 'The article is a draft with its sources.', cost_usd: 0.0124, duration_ms: 154000,
+ model: 'claude-sonnet-4-5', maker: 'Anthropic', href: 'https://openvibe.wiki/a/dock' }),
+ ],
+ failed: Array.from({ length: 6 }, (_, i) => item({ id: `fail_${i}`, service: 'actor', agent: 'Actor', kind: 'agent_task', bucket: 'failed', state: 'failed',
+ skill: 'blog.write', subject: { kind: 'post', title: 'Draft a blog post', url: null }, title: 'Draft a blog post',
+ error: 'No model could take this task.',
+ run_error: { code: 'route.unavailable', words: 'No model could take this task.', next: 'Pick a model or add an AI key, then retry.', action: 'pick_model' },
+ href: 'https://openvibe.actor/tasks/fail_0', updated_at: iso(3600e3 * (i + 1)) })),
+ },
+};
+// The Dock v2's Chats row and the Vibe list: a person's AI conversations, newest first.
+const DOCK_VIBE = {
+ count: 12,
+ conversations: [
+ { slug: 'v-1', name: 'Patch notes for 4.162', last_message_at: iso(4 * 60e3), created_at: iso(2 * 3600e3), last_line: 'I wrote the first draft.', last_answer_at: iso(4 * 60e3), site: 'openvibe.blog' },
+ { slug: 'v-2', name: 'What is on today?', last_message_at: iso(90 * 60e3), created_at: iso(26 * 3600e3), last_line: 'Three things, starting with the release.', last_answer_at: iso(90 * 60e3), site: 'openvibe.today' },
+ { slug: 'v-3', name: 'Memes for the launch', last_message_at: iso(2 * 86400e3), created_at: iso(9 * 86400e3), last_line: 'Here are three, pick one.', last_answer_at: iso(2 * 86400e3), site: 'openvibe.meme' },
+ ],
+};
const NOTES = [
const NOTES = [
{ id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) },
{ id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) },
{ id: 2, type: 'mention', category: 'social', service: 'chat', title: 'kai mentioned you', message: 'look at the shots?', priority: 'normal', is_read: 0, created_at: iso(900e3) },
{ id: 2, type: 'mention', category: 'social', service: 'chat', title: 'kai mentioned you', message: 'look at the shots?', priority: 'normal', is_read: 0, created_at: iso(900e3) },
@@ -65,7 +108,7 @@ const vibeMsgs = [], asked = [], listened = [], undone = [];
@@ -65,7 +108,7 @@ const vibeMsgs = [], asked = [], listened = [], undone = [];
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
-<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
+<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}@media(prefers-color-scheme:light){:root{--bg:#fff;--bg-primary:#fff;--bg-elevated:#fff;--bg-secondary:#f4f6fa;--text-primary:#18202f;--text-secondary:#5d6779;--border:#dce0e8;--accent:#2365dc;--on-accent:#fff}}</style></head>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<script src="/navbar.js"></script>
<script src="/navbar.js"></script>
<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin, dockAgentBase: location.origin,
<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin, dockAgentBase: location.origin,
@@ -85,7 +128,11 @@ const server = http.createServer((req, res) => {
@@ -85,7 +128,11 @@ const server = http.createServer((req, res) => {
if (req.method === 'PUT') { const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); puts.push(b); scenario.state = { ...(scenario.state || defaultState()), ...b, place: { ...((scenario.state || defaultState()).place), ...(b.place || {}) } }; }
if (req.method === 'PUT') { const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); puts.push(b); scenario.state = { ...(scenario.state || defaultState()), ...b, place: { ...((scenario.state || defaultState()).place), ...(b.place || {}) } }; }
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
}
}
- if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
+ if (u.pathname === '/api/v1/me/actions') return json(200, scenario.actions || ACTIONS);
+ // Actor's skill catalogue, as the Dock's overview reads it: through Network's actions.
+ if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: (scenario.skills || [1, 2, 3, 4, 5, 6]).map((n) => ({
+ id: `skl_${n}`, skill: { slug: `skill-${n}`, name: ['Make an image', 'Make a wiki article', 'Write a blog post', 'Improve an article', 'Make a meme', 'Summarise a thread'][n - 1] },
+ scan: { state: 'passed' }, curator: { state: 'active' } })) });
// Codes' agent view (K-D3): the stream, its two controls, and the still.
// Codes' agent view (K-D3): the stream, its two controls, and the still.
if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {
if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {
assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the agent view is read with the person\'s own token');
assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the agent view is read with the person\'s own token');
@@ -132,6 +179,7 @@ const server = http.createServer((req, res) => {
@@ -132,6 +179,7 @@ const server = http.createServer((req, res) => {
.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
return json(200, { messages: all.filter((m) => m.id > after) });
return json(200, { messages: all.filter((m) => m.id > after) });
}
}
+ if (u.pathname === '/api/vibe/conversations') return json(200, scenario.vibe || { conversations: [], count: 0 });
if (u.pathname === '/api/vibe/chat') {
if (u.pathname === '/api/vibe/chat') {
assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'Vibe is asked with the person\'s own token');
assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'Vibe is asked with the person\'s own token');
const after = Number(u.searchParams.get('after')) || 0;
const after = Number(u.searchParams.get('after')) || 0;
@@ -183,7 +231,7 @@ const PILL = `(() => { const p = document.getElementById('ovdk-pill'); if (!p ||
@@ -183,7 +231,7 @@ const PILL = `(() => { const p = document.getElementById('ovdk-pill'); if (!p ||
clear: getComputedStyle(document.documentElement).getPropertyValue('--ov-dock-clear').trim() }; })()`;
clear: getComputedStyle(document.documentElement).getPropertyValue('--ov-dock-clear').trim() }; })()`;
const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hidden) return null; const b = c.getBoundingClientRect();
const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hidden) return null; const b = c.getBoundingClientRect();
return { corner: c.dataset.corner, w: Math.round(b.width), h: Math.round(b.height), l: b.left, t: b.top, tab: (c.querySelector('[role=tab][aria-selected=true]') || {}).dataset?.tab,
return { corner: c.dataset.corner, w: Math.round(b.width), h: Math.round(b.height), l: b.left, t: b.top, tab: (c.querySelector('[role=tab][aria-selected=true]') || {}).dataset?.tab,
- groups: [...c.querySelectorAll('.ovac-group')].map((g) => g.dataset.bucket), items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;
+ groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;
// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the
// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the
// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.
// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.
@@ -216,6 +264,49 @@ async function drag(p, from, to) {
@@ -216,6 +264,49 @@ async function drag(p, from, to) {
const browser = await h.launch({ tmpDir });
const browser = await h.launch({ tmpDir });
const fresh = async (width, height = 860) => { const context = await browser.newContext(); const p = await h.openPage(browser, context, { width, height }); return p; };
const fresh = async (width, height = 860) => { const context = await browser.newContext(); const p = await h.openPage(browser, context, { width, height }); return p; };
try {
try {
+ if (process.env.DOCK_SHOTS_DIR) {
+ fs.mkdirSync(process.env.DOCK_SHOTS_DIR, { recursive: true });
+ const shot = async (p, name) => {
+ await wait(400);
+ const docWidth = await p.evaluate('document.documentElement.scrollWidth');
+ assert.ok(docWidth <= p.width && docWidth >= p.width - 20, `${p.width} px: no sideways scroll (${docWidth})`);
+ const pic = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
+ fs.writeFileSync(path.join(process.env.DOCK_SHOTS_DIR, `${name}-${p.width}-${p.scheme}.png`), Buffer.from(pic.data, 'base64'));
+ };
+ for (const width of [390, 1280]) for (const scheme of ['dark', 'light']) {
+ // The Agents list: a running run, one waiting, one scheduled, two done and six identical failures.
+ scenario = { counts: { running: 1, waiting: 1, unread: 0 }, actions: DOCK_ACTIONS, vibe: DOCK_VIBE };
+ const p = await fresh(width, 900);
+ p.width = width; p.scheme = scheme;
+ await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!window.OpenVibeDock", 'the Dock');
+ await p.evaluate("window.OpenVibeDock.open({ tab: 'agent' })");
+ await until(p, "document.querySelectorAll('.ovdk-run').length >= 4", 'the screenshot runs');
+ await shot(p, 'agents');
+
+ // The overview: nothing runs, so the tab says what is next, what happened and what agents can do.
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true },
+ actions: { ...DOCK_ACTIONS, counts: { running: 0, waiting: 0, scheduled: 1, done: 2, failed: 6 } }, vibe: DOCK_VIBE };
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!window.OpenVibeDock", 'the Dock');
+ await p.evaluate("window.OpenVibeDock.open({ tab: 'agent' })");
+ await until(p, "!!document.querySelector('.ovdk-overview')", 'the overview');
+ await shot(p, 'overview');
+
+ // The Vibe tab: the list of the person's conversations.
+ await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
+ await until(p, "document.querySelectorAll('.ovdk-vibe-list button').length >= 3", 'the Vibe list');
+ await shot(p, 'vibe');
+
+ // The Chats tab: the AI chats row among the DMs.
+ await p.evaluate("document.querySelector('#ovdk-tab-chats').click()");
+ await until(p, "!!document.querySelector('.ovdk-chat-b b')", 'the Chats rows');
+ await shot(p, 'chats');
+ await p.close();
+ }
+ if (process.env.DOCK_SHOTS_ONLY) return;
+ }
// ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──
// ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──
{
{
scenario = { counts: { running: 0, waiting: 0, unread: 3 } };
scenario = { counts: { running: 0, waiting: 0, unread: 3 } };
@@ -537,6 +628,8 @@ async function drag(p, from, to) {
@@ -537,6 +628,8 @@ async function drag(p, from, to) {
await until(p, CARD, 'the card');
await until(p, CARD, 'the card');
assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');
assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');
await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
+ await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
+ await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');
await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');
await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
@@ -582,6 +675,8 @@ async function drag(p, from, to) {
@@ -582,6 +675,8 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, PILL, 'the pinned pill');
await until(p, PILL, 'the pinned pill');
await p.evaluate("document.getElementById('ovdk-pill').click()");
await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
+ await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');
assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+/**
+ * The Dock's Agents tab (plan 2026-11-09 §7–8): the content-aware run rows and the overview that replaces an empty
+ * middle. The rows say what a run was about, one status line says where it is, repeats fold into one row, and a
+ * failure says why in words and what happens next — never the provider's own JSON.
+ *
+ * The classes are the design's: ovdk-run, ovdk-run-ic, ovdk-run-sub, ovdk-overview, ovdk-tiles, ovdk-skills and
+ * ovdk-vibe-list. Opus refines from here, so the names are part of the contract.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const runs = fs.readFileSync(path.join(__dirname, '..', 'dock-runs.js'), 'utf8');
+const vibe = fs.readFileSync(path.join(__dirname, '..', 'dock-vibe.js'), 'utf8');
+const dock = fs.readFileSync(path.join(__dirname, '..', 'dock.js'), 'utf8');
+const marks = fs.readFileSync(path.join(__dirname, '..', 'ai-marks-client.js'), 'utf8');
+const builder = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-ai-marks-client.js'), 'utf8');
+
+// ── the classes the design names ────────────────────────────────────────────
+for (const cls of ['ovdk-run', 'ovdk-run-ic', 'ovdk-run-sub', 'ovdk-overview', 'ovdk-tiles', 'ovdk-skills']) {
+ assert.ok(runs.includes(cls), `the Agents tab has a ${cls}`);
+}
+assert.ok(vibe.includes('ovdk-vibe-list'), 'the Vibe tab has a list');
+
+// ── the row says what the run was about ─────────────────────────────────────
+assert.ok(runs.includes('it.subject && it.subject.title'), 'the row is titled by its subject');
+assert.ok(runs.includes('subject.kind'), 'the subject carries its kind');
+assert.ok(runs.includes('skill'), 'the row names the skill');
+assert.ok(runs.includes('iconOf'), 'the skill has its own icon');
+for (const [skill, glyph] of [['post', 'fa-pen'], ['wiki', 'fa-book'], ['image', 'fa-image'], ['chat', 'fa-comments'], ['code', 'fa-code'], ['clip', 'fa-film'], ['quest', 'fa-flag']]) {
+ assert.ok(runs.includes(glyph), `the ${skill} skill draws ${glyph}`);
+}
+assert.ok(/--run-color/.test(runs), 'the icon is in the skill\'s own colour');
+
+// ── one status line per state ───────────────────────────────────────────────
+assert.ok(runs.includes("st = 'Running'"), 'a running run says what it is doing');
+assert.ok(runs.includes("st = 'Waiting'"), 'a waiting run says what it needs');
+assert.ok(runs.includes("st = 'Scheduled'"), 'a scheduled run says when');
+assert.ok(runs.includes("st = 'Failed'"), 'a failed run says why');
+assert.ok(runs.includes('cost_usd'), 'a finished run says what it cost');
+assert.ok(runs.includes('duration_ms'), 'a finished run says how long it took');
+assert.ok(runs.includes('it.model'), 'a finished run names the model');
+assert.ok(runs.includes('maker'), 'the model carries its maker');
+assert.ok(runs.includes('OpenVibeAIMarks'), 'the maker\'s mark is drawn');
+assert.ok(runs.includes('ovdk-run-bar'), 'a running run has a thin bar');
+
+// ── a failure says why, and what happens next ───────────────────────────────
+assert.ok(runs.includes('run_error'), 'the failure carries its safe error');
+assert.ok(runs.includes('error.words'), 'the failure shows the words');
+assert.ok(runs.includes('error.next'), 'the failure shows the next step');
+assert.ok(!runs.includes('it.error_detail'), 'the raw provider text never reaches the row');
+assert.ok(!/innerHTML\s*=\s*[^;]*error/.test(runs), 'no error is written as markup');
+
+// ── the row's actions ───────────────────────────────────────────────────────
+assert.ok(runs.includes("h('a', 'ovdk-run-title')"), 'a row opens from its title');
+assert.ok(runs.includes("'Retry'"), 'a failed run can be retried');
+assert.ok(runs.includes("'Stop'"), 'a running run can be stopped');
+assert.ok(runs.includes('Run again with another model'), 'a run can be made again with another model');
+assert.ok(runs.includes('Pick a model'), 'a failure that needs a model says so');
+assert.ok(runs.includes('Add a key'), 'and one that needs a key says so');
+
+// ── repeats fold into one row ───────────────────────────────────────────────
+assert.ok(runs.includes('grouped'), 'the runs are grouped');
+assert.ok(runs.includes('`×${group.length}'), 'a group says how many it holds');
+assert.ok(runs.includes('ovdk-run-repeat'), 'a group expands');
+
+// ── the overview, instead of an empty middle ────────────────────────────────
+assert.ok(runs.includes("h('small', '', 'Next')") && runs.includes('Nothing scheduled'), 'the overview says what runs next');
+assert.ok(runs.includes('ovdk-tile'), 'the numbers are small tiles');
+assert.ok(runs.includes('Today'), "the overview says today's numbers");
+assert.ok(runs.includes('This week'), 'and this week\'s');
+assert.ok(runs.includes('Last results'), 'the overview keeps the last results');
+assert.ok(runs.includes('Things agents can do for you'), 'the overview offers the catalogue');
+assert.ok(runs.includes('/api/v1/me/actions/skills'), 'the catalogue is Actor\'s, read through Network');
+assert.ok(runs.includes("'All runs'"), 'the foot keeps All runs');
+assert.ok(runs.includes("'Give an agent a task'"), 'and Give an agent a task');
+
+// ── the phone: 44 px, no spinners ───────────────────────────────────────────
+assert.ok(/min-height:44px/.test(runs), 'every control is 44 px on a phone');
+assert.ok(!runs.includes('spinner'), 'there is no spinner');
+assert.ok(!runs.includes('innerHTML'), 'the tab writes no markup of its own');
+
+// ── the Vibe tab: a list, a back arrow, and why it did not answer ───────────
+assert.ok(vibe.includes('openList'), 'the Vibe tab has a list');
+assert.ok(vibe.includes('ovdk-vibe-back'), 'a conversation has a back arrow');
+assert.ok(vibe.includes('20 * 60000'), 'the tab opens on the conversation you had within twenty minutes');
+assert.ok(vibe.includes('ctx.state'), 'the choice is the Dock\'s synced state');
+assert.ok(vibe.includes('ctx.save'), 'and it is written back');
+assert.ok(vibe.includes('Vibe did not answer'), 'a failure says so');
+assert.ok(vibe.includes("'Retry'"), 'and offers a retry');
+assert.ok(vibe.includes('ovdk-attach'), 'the composer has an attach');
+assert.ok(vibe.includes('ovdk-mic'), 'and the mic');
+assert.ok(!vibe.includes('box.focus()') || /touch|coarse|pointer/.test(vibe), 'the composer does not take the focus on a touch screen');
+
+// ── the Dock loads them, and the Chats row opens the Vibe tab ──────────────
+assert.ok(dock.includes("load('dock-runs.js'"), 'the Dock loads the Agents tab');
+assert.ok(dock.includes("load('ai-marks-client.js'"), 'and the maker marks with it');
+assert.ok(dock.includes('ovdk-runs-foot'), 'the Agents tab has its own foot');
+assert.ok(dock.includes('AI chats'), "the Chats tab has the AI chats row");
+assert.ok(dock.includes('vibeOnList'), 'and it opens the Vibe tab on its list');
+assert.ok(dock.includes('groupAvatar'), "a group's row shows faces, not a number sign");
+assert.ok(dock.includes('answered'), 'and when Vibe last answered');
+assert.ok(dock.includes('ovdk-size::after') || dock.includes('.ovdk-size'), 'the Dock has a grip to resize it with');
+
+console.log("dock v2: the run rows, the overview, the Vibe list and the Chats row");
@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
-const DOCK_BROTLI_BUDGET = 18944; // 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
-const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
+const DOCK_BROTLI_BUDGET = 19456; // 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
+const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{
@@ -50,6 +50,8 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
@@ -50,6 +50,8 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
{ service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });
{ service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
+ if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);
+ if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);
if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s.startsWith('https://')) return J({}, false, 404);
if (s.startsWith('https://')) return J({}, false, 404);
@@ -193,8 +195,27 @@ const server = http.createServer((req, res) => {
@@ -193,8 +195,27 @@ const server = http.createServer((req, res) => {
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");
assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");
+ // Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
+ // uploads through Network and the post names them with that text.
+ await s.evaluate(`(async () => {
+ const ta = document.querySelector('.ovh-compose textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input'));
+ const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);
+ const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));
+ const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File([blob], 'b.png', { type: 'image/png' }));
+ const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
+ })(), true`);
+ assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
+ assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows');
+ await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
+ assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on');
+ await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
+ assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
+ const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
+ assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');
+ assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');
+ assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);
await s.close();
await s.close();
- console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer, Network sync ok');
+ console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -36,22 +36,23 @@ const sw = require('../sw');
@@ -36,22 +36,23 @@ const sw = require('../sw');
assert.strictEqual(sw.linkHeader({ href: '/x.css', as: 'style', type: 'text/css' }), '</x.css>; rel=preload; as=style; type=text/css');
assert.strictEqual(sw.linkHeader({ href: '/x.css', as: 'style', type: 'text/css' }), '</x.css>; rel=preload; as=style; type=text/css');
assert.strictEqual(sw.linkHeader(null), '');
assert.strictEqual(sw.linkHeader(null), '');
- // The 103 goes out first, and the Link header rides on the page so a proxy that drops the 103 still hints.
+ // By default only the Link header: nginx 1.28 takes an upstream 103 as the final answer (11-09).
let hints103 = null; const headers = {};
let hints103 = null; const headers = {};
const res = {
const res = {
headersSent: false,
headersSent: false,
writeEarlyHints(h) { hints103 = h; return true; },
writeEarlyHints(h) { hints103 = h; return true; },
setHeader(k, v) { headers[k] = v; },
setHeader(k, v) { headers[k] = v; },
};
};
- assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), true);
- assert.deepStrictEqual(hints103, { link: ['</shared/navbar.js>; rel=preload; as=script'] });
+ assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), false, 'no 103 by default');
+ assert.strictEqual(hints103, null);
assert.strictEqual(headers.Link, '</shared/navbar.js>; rel=preload; as=script');
assert.strictEqual(headers.Link, '</shared/navbar.js>; rel=preload; as=script');
- // A runtime that cannot send a 103 still gets the header.
+ // Where the proxy passes a 103 on (send103 or OV_EARLY_HINTS=1), it goes first and the header still rides.
let noHints = null; const headers2 = {};
let noHints = null; const headers2 = {};
const res2 = { headersSent: false, writeEarlyHints(h) { noHints = h; }, setHeader(k, v) { headers2[k] = v; } };
const res2 = { headersSent: false, writeEarlyHints(h) { noHints = h; }, setHeader(k, v) { headers2[k] = v; } };
- assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }]), true);
- assert.ok(noHints, 'the 103 was sent');
+ assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }], { send103: true }), true);
+ assert.deepStrictEqual(noHints, { link: ['</a.css>; rel=preload; as=style'] }, 'the 103 was sent');
+ assert.strictEqual(headers2.Link, '</a.css>; rel=preload; as=style');
const res3 = { headersSent: true, setHeader() { throw new Error('the header was already sent'); } };
const res3 = { headersSent: true, setHeader() { throw new Error('the header was already sent'); } };
assert.strictEqual(sw.earlyHints(res3, [{ href: '/a.css', as: 'style' }]), false, 'a response already on its way is left alone');
assert.strictEqual(sw.earlyHints(res3, [{ href: '/a.css', as: 'style' }]), false, 'a response already on its way is left alone');
assert.strictEqual(sw.earlyHints(res, []), false, 'no links, no hint');
assert.strictEqual(sw.earlyHints(res, []), false, 'no links, no hint');