openvibers/openvibe.shared Commits

4.168.0: the Dock v2 (runs, overview, Vibe list) and the Feed composer's pictures (#386)

OP OpenVibers committed 4 days ago · b7740e8b4f · 21 files, +706 −66

* Dock v2: the run rows, the overview and the Vibe list

* 4.165.0: the Dock's run rows, the overview and the Vibe list

The Agents tab showed 34 identical rows, 25 failures in the provider's own words and
an empty middle. Now every run says what it was about: the skill's icon in its own
colour, the subject as the title, and one status line — the live step while it runs,
what it needs while it waits, when it is due, the result, the cost, the time and the
model with its maker's mark when it is done, and the words and the next step when it
fails. Six identical failures in a day are one row that expands.

The empty middle is now the overview: the next scheduled run, today's and this week's
numbers as three tiles, the last results, and six skills from Actor's catalogue that
start a run in one tap.

The Vibe tab has a conversation list and a back arrow, opens on the conversation you
had within the last twenty minutes, and keeps that choice in the Dock's synced state.
The Chats tab has an AI chats row among the DMs, and a group's row shows faces.

* A failure says what the person can do about it

The error's own action drives the row: a run that needs a model offers Pick a model,
one that needs a key offers Add a key, and the menu's entry says run again with
another model.

* Merge origin/main into the Dock v2 branch

The 4.165.0 release took the version number; this branch is 4.166.0.

* The measured size in the budget note

* Dock v2, restyled: runs as a list with coloured states and pill actions, number-first tiles, the Vibe list with its sites, the AI chats avatar

* Merge remote-tracking branch 'origin/main' into auto/dock-v2-agents-vibe

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	navbar.js
#	package-lock.json
#	package.json

* The Dock's one-tap skills are read through Network (every site's CSP refuses openvibe.actor)

* The extension's read-only Dock has no approve, decline, stop or retry

* The Feed's composer takes pictures: up to four, each described, uploaded through Network, then posted

* 4.168.0 notes: the composer's pictures, the skills relay, the extension's read-only Dock

* Early hints: the Link header only unless the proxy passes a 103 on (nginx 1.28 takes it as the final answer)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 41b7d52..982f81f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 43140ae..4ac414f 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex b6dc5c0..50bf4c9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/ai-marks-client.js b/ai-marks-client.js
nnew file mode 100644
iindex 0000000..ec3d416
---- /dev/null
++++ b/ai-marks-client.js
@@@ -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);
ddiff --git a/dock-runs.js b/dock-runs.js
nnew file mode 100644
iindex 0000000..5bf504c
---- /dev/null
++++ b/dock-runs.js
@@@ -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);
ddiff --git a/dock-timeline.js b/dock-timeline.js
iindex 93cba7c..1b191b8 100644
---- a/dock-timeline.js
++++ b/dock-timeline.js
@@@ -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 || {});
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 367b45e..24ba2a7 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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 };
ddiff --git a/dock.js b/dock.js
iindex 06582f0..64d1a0f 100644
---- a/dock.js
++++ b/dock.js
@@@ -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;
ddiff --git a/files.js b/files.js
iindex 9e05488..4ff403a 100644
---- a/files.js
++++ b/files.js
@@@ -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
ddiff --git a/navbar.js b/navbar.js
iindex 7434bdc..c672fe1 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/ov-home.js b/ov-home.js
iindex 799a87a..7021ff4 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 83c51a8..71bcd8f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a6b1a26..602a9fe 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/build-ai-marks-client.js b/scripts/build-ai-marks-client.js
nnew file mode 100644
iindex 0000000..0eab6e9
---- /dev/null
++++ b/scripts/build-ai-marks-client.js
@@@ -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);
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 7a97ecf..9a2b6e1 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -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(', ')} };`;
ddiff --git a/sw.js b/sw.js
iindex 55e166d..da21662 100644
---- a/sw.js
++++ b/sw.js
@@@ -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;
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 698298b..745bf13 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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 }))");
ddiff --git a/test/dock-runs.test.js b/test/dock-runs.test.js
nnew file mode 100644
iindex 0000000..7a12625
---- /dev/null
++++ b/test/dock-runs.test.js
@@@ -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");
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 30dc7c0..0163c5b 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -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 ──────────────────────────────────────────────
{
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 7120223..ab6be5e 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -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();
ddiff --git a/test/sw.test.js b/test/sw.test.js
iindex 9fbc5af..f5ad07e 100644
---- a/test/sw.test.js
++++ b/test/sw.test.js
@@@ -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');