openvibers/openvibe.link Commits

Link's window: Codes on this computer (#129)

OP OpenVibers committed 6 days ago · 4a6951976c · 6 files, +257 −16

Changes

ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
iindex d5e4b1e..5634520 100644
---- a/internal/panel/panel.go
++++ b/internal/panel/panel.go
@@@ -101,6 +101,32 @@ type Activity struct {
Text string `json:"text"`
}
++// CodesAgent is one coding-agent CLI on this computer: ready, needs signing in (Hint is the command), or missing.
++type CodesAgent struct {
++ ID string `json:"id"`
++ Name string `json:"name"`
++ State string `json:"state"`
++ Version string `json:"version,omitempty"`
++ Hint string `json:"hint,omitempty"`
++}
++
++// CodesTask is one Codes task that runs, or ran, on this computer.
++type CodesTask struct {
++ ID string `json:"id"`
++ Title string `json:"title"`
++ Folder string `json:"folder,omitempty"`
++ Agent string `json:"agent,omitempty"`
++ State string `json:"state"`
++ StartedAt time.Time `json:"started_at,omitempty"`
++ URL string `json:"url,omitempty"`
++}
++
++// Codes is what the window's Codes section reads.
++type Codes struct {
++ Agents []CodesAgent `json:"agents,omitempty"`
++ Tasks []CodesTask `json:"tasks,omitempty"`
++}
++
// State is GET /panel/api/state. Every key is optional: a section with nothing in it is left out.
type State struct {
Machine *Machine `json:"machine,omitempty"`
@@@ -111,6 +137,8 @@ type State struct {
GPU *GPU `json:"gpu,omitempty"`
EarningsToday *Earnings `json:"earnings_today,omitempty"`
Activity []Activity `json:"activity,omitempty"`
++ // Codes is the desktop app's Codes module (§13.1): the agent CLIs on this computer and the Codes tasks here.
++ Codes *Codes `json:"codes,omitempty"`
// FirstRun is true until the person has answered "What's this computer for?" or skipped it.
FirstRun bool `json:"first_run,omitempty"`
}
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
iindex 1a3ddb0..fec80f2 100644
---- a/internal/panel/panel_test.go
++++ b/internal/panel/panel_test.go
@@@ -154,6 +154,7 @@ func TestStateShapeMatchesTheDesign(t *testing.T) {
EarningsToday: &Earnings{Coins: 120},
Activity: []Activity{{At: now, Text: "x"}},
FirstRun: true,
++ Codes: &Codes{Agents: []CodesAgent{{ID: "claude-code", Name: "Claude Code", State: "ready"}}, Tasks: []CodesTask{{ID: "t", Title: "t", State: "running", StartedAt: now}}},
}
b, err := json.Marshal(full)
if err != nil {
ddiff --git a/internal/panel/ui/index.html b/internal/panel/ui/index.html
iindex c2cb8ec..979385b 100644
---- a/internal/panel/ui/index.html
++++ b/internal/panel/ui/index.html
@@@ -67,6 +67,14 @@
</div>
<div class="lk-col lk-col-wide">
++ <section class="lk-sec lk-codes" data-lk-sec="codes" aria-labelledby="lk-codes-h" hidden>
++ <h2 id="lk-codes-h">Codes on this computer <span data-lk-codes-n></span></h2>
++ <ul class="lk-agents" data-lk-agents aria-label="Coding agents on this computer"></ul>
++ <ul class="lk-tasks" data-lk-tasks></ul>
++ <p class="lk-none" data-lk-tasks-none>No coding task here yet. Start one from Codes and pick this computer.</p>
++ <p class="lk-codes-acts"><a class="lk-btn is-primary" data-lk-codes-new href="https://openvibe.codes/" target="_blank" rel="noopener">New task on this computer</a><a class="lk-btn" href="https://openvibe.codes/tasks" target="_blank" rel="noopener">All your tasks</a></p>
++ </section>
++
<section class="lk-sec" data-lk-sec="gpu" aria-labelledby="lk-gpu-h" hidden>
<h2 id="lk-gpu-h">Models on this computer</h2>
<p class="lk-gpu-name" data-lk-gpu-name></p>
ddiff --git a/internal/panel/ui/panel.css b/internal/panel/ui/panel.css
iindex b456215..8ee9b1d 100644
---- a/internal/panel/ui/panel.css
++++ b/internal/panel/ui/panel.css
@@@ -63,6 +63,30 @@ button, input { font: inherit; color: inherit; }
.lk-btn { min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; cursor: pointer; font-weight: 700; font-size: 13px; }
.lk-btn:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
.lk-btn.is-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
++/* Codes on this computer (the desktop app's Codes module, §13.1): which agent CLIs are here and ready, and the tasks. */
++.lk-agents { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
++.lk-agent { display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); font-size: 13px; font-weight: 650; }
++.lk-agent::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
++.lk-agent[data-state="ready"]::before { background: var(--ok); }
++.lk-agent[data-state="signin"]::before { background: var(--warn); }
++.lk-agent[data-state="missing"] { color: var(--muted); border-style: dashed; }
++.lk-agent small { color: var(--muted); font-weight: 500; }
++.lk-agent code { font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 3px 6px; border-radius: 6px; background: var(--surface-2); color: var(--text); }
++.lk-agent button { min-height: 24px; padding: 0 8px; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--text) 9%, transparent); cursor: pointer; font-size: 12px; font-weight: 700; }
++.lk-tasks { display: grid; gap: 6px; }
++.lk-tasks li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 12px; background: var(--surface-2); }
++.lk-tasks b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.lk-tasks small { display: flex; flex-wrap: wrap; gap: 2px 8px; color: var(--muted); font-size: 12px; min-width: 0; }
++.lk-tasks code { font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
++.lk-task-acts { display: flex; gap: 6px; }
++.lk-state { font-weight: 700; }
++.lk-state[data-state="running"] { color: var(--accent-2); }
++.lk-state[data-state="waiting"] { color: var(--warn); }
++.lk-state[data-state="done"] { color: var(--ok); }
++.lk-state[data-state="failed"], .lk-state[data-state="stopped"] { color: var(--bad); }
++.lk-codes-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }
++a.lk-btn { display: inline-flex; align-items: center; text-decoration: none; color: inherit; }
++a.lk-btn.is-primary { color: var(--on-accent); }
.lk-btn.is-stop { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); }
/* Capabilities: a switch each, and who may use it in one line. */
ddiff --git a/internal/panel/ui/panel.js b/internal/panel/ui/panel.js
iindex 94e494a..9057531 100644
---- a/internal/panel/ui/panel.js
++++ b/internal/panel/ui/panel.js
@@@ -21,6 +21,55 @@
.then(function () { load(); });
}
++ // Codes on this computer: each agent CLI's state in words, and the tasks with Open and (while working) Stop.
++ var AGENT_STATE = { ready: 'ready', signin: 'sign in', missing: 'not installed' };
++ var TASK_STATE = { running: 'Working', waiting: 'Waiting for you', done: 'Done', failed: 'Didn\u2019t finish', stopped: 'Stopped' };
++ function drawCodes(c, machine) {
++ var sec = root.querySelector('[data-lk-sec="codes"]');
++ sec.hidden = !c;
++ if (!c) return;
++ var agents = c.agents || [], tasks = c.tasks || [];
++ $('[data-lk-agents]').replaceChildren.apply($('[data-lk-agents]'), agents.map(function (a) {
++ var li = el('li', 'lk-agent'); li.setAttribute('data-state', a.state || 'missing');
++ li.append(document.createTextNode(a.name || a.id), el('small', '', AGENT_STATE[a.state] || a.state || ''));
++ if (a.state === 'signin' && a.hint) {
++ var code = el('code', '', a.hint); var copy = el('button', '', 'Copy'); copy.type = 'button';
++ copy.setAttribute('aria-label', 'Copy ' + a.hint);
++ copy.addEventListener('click', function () { if (navigator.clipboard) navigator.clipboard.writeText(a.hint).then(function () { copy.textContent = 'Copied'; }); });
++ li.append(code, copy);
++ }
++ return li;
++ }));
++ $('[data-lk-agents]').hidden = agents.length === 0;
++ var open = tasks.filter(function (t) { return t.state === 'running' || t.state === 'waiting'; }).length;
++ $('[data-lk-codes-n]').textContent = open ? '(' + open + ' working)' : '';
++ $('[data-lk-tasks]').replaceChildren.apply($('[data-lk-tasks]'), tasks.map(function (t) {
++ var li = el('li'); var main = el('span');
++ var meta = el('small');
++ var st = el('span', 'lk-state', TASK_STATE[t.state] || t.state || ''); st.setAttribute('data-state', t.state || '');
++ meta.append(st);
++ if (t.agent) meta.append(el('span', '', t.agent));
++ if (t.started_at) meta.append(el('span', '', ago(t.started_at)));
++ main.append(el('b', '', t.title || 'A coding task'));
++ if (t.folder) main.append(el('code', '', t.folder));
++ main.append(meta);
++ var acts = el('span', 'lk-task-acts');
++ if (t.url && /^https:\/\/openvibe\.codes\//.test(t.url)) {
++ var a = el('a', 'lk-btn', t.state === 'waiting' ? 'Answer' : 'Open'); a.href = t.url; a.target = '_blank'; a.rel = 'noopener';
++ if (t.state === 'waiting') a.className = 'lk-btn is-primary';
++ acts.append(a);
++ }
++ if (t.state === 'running') {
++ var stop = el('button', 'lk-btn is-stop', 'Stop'); stop.type = 'button'; stop.setAttribute('aria-label', 'Stop ' + (t.title || 'this task'));
++ stop.addEventListener('click', function () { stop.disabled = true; send('codes/tasks/' + encodeURIComponent(t.id) + '/stop').catch(function () { stop.disabled = false; }); });
++ acts.append(stop);
++ }
++ li.append(main, acts); return li;
++ }));
++ $('[data-lk-tasks-none]').hidden = tasks.length > 0;
++ var nw = $('[data-lk-codes-new]');
++ if (machine && machine.id) nw.href = 'https://openvibe.codes/?machine=' + encodeURIComponent(machine.id);
++ }
function draw(s) {
var m = s.machine || {};
var running = Array.isArray(s.running) ? s.running : [];
@@@ -31,6 +80,7 @@
$('[data-lk-down]').hidden = true;
$('[data-lk-first]').hidden = !s.first_run;
$('[data-lk-name]').textContent = m.name || 'This computer';
++ drawCodes(s.codes, m);
$('[data-lk-status]').textContent = m.paused ? 'Paused: nothing runs until you resume'
: m.needs_you ? (m.needs_you === 1 ? 'Online · something needs you' : 'Online · ' + m.needs_you + ' things need you')
: running.length ? 'Online · ' + running.length + ' running' : 'Online · ready';
ddiff --git a/internal/panel/ui/testdata/state.json b/internal/panel/ui/testdata/state.json
iindex c08ddac..06cb12c 100644
---- a/internal/panel/ui/testdata/state.json
++++ b/internal/panel/ui/testdata/state.json
@@@ -1,28 +1,158 @@
{
"first_run": true,
-- "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },
++ "machine": {
++ "name": "Workshop desktop",
++ "os": "linux",
++ "online": true,
++ "paused": false,
++ "needs_you": 1
++ },
"running": [
-- { "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" },
-- { "id": "r_2", "kind": "desk", "title": "A desk for Codes: drive redesign", "detail": "Codes", "started_at": "2026-11-07T09:40:00Z" },
-- { "id": "r_3", "kind": "share", "title": "Port 3000 shared", "detail": "Only you", "started_at": "2026-11-07T09:10:00Z" }
++ {
++ "id": "r_1",
++ "kind": "agent",
++ "title": "Vibe is comparing three hosting plans",
++ "detail": "Browser",
++ "started_at": "2026-11-07T10:02:00Z"
++ },
++ {
++ "id": "r_2",
++ "kind": "desk",
++ "title": "A desk for Codes: drive redesign",
++ "detail": "Codes",
++ "started_at": "2026-11-07T09:40:00Z"
++ },
++ {
++ "id": "r_3",
++ "kind": "share",
++ "title": "Port 3000 shared",
++ "detail": "Only you",
++ "started_at": "2026-11-07T09:10:00Z"
++ }
],
"capabilities": [
-- { "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true },
-- { "id": "files", "name": "Files between your devices", "who": "Only you", "on": true },
-- { "id": "models", "name": "Run AI models here", "who": "Only you, from your other devices", "on": false },
-- { "id": "earn", "name": "Earn with spare power", "who": "OpenVibe Exchange, when this computer is idle", "on": false }
++ {
++ "id": "browser",
++ "name": "A browser for your agents",
++ "who": "Your agents, after you approve each task",
++ "on": true
++ },
++ {
++ "id": "files",
++ "name": "Files between your devices",
++ "who": "Only you",
++ "on": true
++ },
++ {
++ "id": "models",
++ "name": "Run AI models here",
++ "who": "Only you, from your other devices",
++ "on": false
++ },
++ {
++ "id": "earn",
++ "name": "Earn with spare power",
++ "who": "OpenVibe Exchange, when this computer is idle",
++ "on": false
++ }
++ ],
++ "shares": [
++ {
++ "port": 3000,
++ "url": "https://dev--alex.openvibe.website",
++ "expires_at": "2026-11-07T22:00:00Z"
++ }
],
-- "shares": [ { "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" } ],
"devices": [
-- { "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true },
-- { "name": "Laptop", "kind": "laptop", "online": true, "here": false },
-- { "name": "Pixel 8", "kind": "phone", "online": false, "here": false }
++ {
++ "name": "Workshop desktop",
++ "kind": "desktop",
++ "online": true,
++ "here": true
++ },
++ {
++ "name": "Laptop",
++ "kind": "laptop",
++ "online": true,
++ "here": false
++ },
++ {
++ "name": "Pixel 8",
++ "kind": "phone",
++ "online": false,
++ "here": false
++ }
],
"gpu": null,
"earnings_today": null,
"activity": [
-- { "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" },
-- { "at": "2026-11-07T09:40:00Z", "text": "Codes started a desk for the drive redesign" },
-- { "at": "2026-11-07T09:10:00Z", "text": "You shared port 3000" }
-- ]
++ {
++ "at": "2026-11-07T10:02:00Z",
++ "text": "Vibe opened a browser to compare hosting plans"
++ },
++ {
++ "at": "2026-11-07T09:40:00Z",
++ "text": "Codes started a desk for the drive redesign"
++ },
++ {
++ "at": "2026-11-07T09:10:00Z",
++ "text": "You shared port 3000"
++ }
++ ],
++ "codes": {
++ "agents": [
++ {
++ "id": "claude-code",
++ "name": "Claude Code",
++ "state": "ready",
++ "version": "2.1.3"
++ },
++ {
++ "id": "codex",
++ "name": "Codex",
++ "state": "signin",
++ "hint": "codex login"
++ },
++ {
++ "id": "opencode",
++ "name": "OpenCode",
++ "state": "ready",
++ "version": "0.9.2"
++ },
++ {
++ "id": "gemini",
++ "name": "Gemini CLI",
++ "state": "missing"
++ }
++ ],
++ "tasks": [
++ {
++ "id": "tsk_01M6Q1",
++ "title": "Make every page work at 320 px",
++ "folder": "~/code/openvibe-site",
++ "agent": "Claude Code",
++ "state": "running",
++ "started_at": "2026-11-07T09:52:00Z",
++ "url": "https://openvibe.codes/tasks/tsk_01M6Q1"
++ },
++ {
++ "id": "tsk_01M6P8",
++ "title": "Fix the flaky upload test",
++ "folder": "~/code/drive",
++ "agent": "Codex",
++ "state": "waiting",
++ "started_at": "2026-11-07T09:31:00Z",
++ "url": "https://openvibe.codes/tasks/tsk_01M6P8"
++ },
++ {
++ "id": "tsk_01M6N2",
++ "title": "Add a dark mode that follows the system",
++ "folder": "~/code/notes-app",
++ "agent": "OpenCode",
++ "state": "done",
++ "started_at": "2026-11-07T08:05:00Z",
++ "url": "https://openvibe.codes/tasks/tsk_01M6N2"
++ }
++ ]
++ }
}