Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified packages/extension/src/amicode_service/widgets.ts
Binary file not shown.
152 changes: 152 additions & 0 deletions packages/extension/src/amicode_service/widgets_src/campaign-digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// AMICODE built-in widget: CAMPAIGN DIGEST — the home-dashboard digest over
// the campaign routes (#678, data contract #658/#662): GET /amicode/campaigns
// (newest-first list) + GET /amicode/campaign?slug= (the ledger's parsed
// sections). The tile renders the active campaign's one-line objective, the
// newest verdict-table entries as compact chips, and the §4 blocked section
// as the needs-you line. Mechanical projection only — the tile renders what
// the routes return, no ledger-markdown parsing beyond display compression
// of the status cells. Any fetch failure or a campaign-less ledger dir
// renders nothing (height-0 empty-state, the jump-back-in discipline).

export const manifestToml = `
id = "campaign-digest"
name = "Campaign digest"
version = "1.0.0"
description = "Live digest of your active research campaign — objective, verdicts, needs-you"
size = "tile"
height = 150
`

export const widgetJs = `
export default {
mount: function (el, amico) {
var esc = function (s) {
return String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Verdict chips: the §2 table's data rows (row 0 is the header per the
// route contract). A chip is the first cell (the slice/unit) plus the
// status cell's leading token — display compression of the raw cell,
// not parsing: ledgers write '**DONE** — PR #17 merged …' and the chip
// shows 'DONE'.
var MAX_VERDICT_CHIPS = 3
var showEmpty = function () {
el.innerHTML = ''
}
var statusToken = function (cell) {
return String(cell || '')
.split('**').join('')
.split('\\u2014')[0]
.split('\\u2013')[0]
.trim()
.split(/\\s+/)
.slice(0, 2)
.join(' ')
}
var chipTone = function (cell) {
var s = String(cell || '').toUpperCase()
if (s.indexOf('DONE') >= 0 || s.indexOf('MERGED') >= 0 || s.indexOf('PASS') >= 0) return 'var(--amc-success)'
if (s.indexOf('BLOCK') >= 0 || s.indexOf('FAIL') >= 0 || s.indexOf('STUCK') >= 0) return 'var(--amc-danger)'
return 'var(--amc-accent)'
}
// Pick the newest campaign whose status is ACTIVE (case-insensitive —
// ledgers write 'ACTIVE' in the frontmatter), falling back to the newest
// overall: a finished campaign still deserves a tile until the next one
// starts. The list is newest-first per the route contract, so the first
// match wins.
var pickCampaign = function (campaigns) {
if (!campaigns || campaigns.length === 0) return null
for (var i = 0; i < campaigns.length; i++) {
var status = String((campaigns[i] && campaigns[i].status) || '').toLowerCase()
if (status === 'active') return campaigns[i]
}
return campaigns[0]
}
// First non-empty line of a section body, bullet + bold markers
// stripped (the same mechanical projection the list route applies to
// the objective line).
var firstLine = function (text) {
var lines = String(text || '').split('\\n')
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim().replace(/^[-*]\\s+/, '').split('**').join('')
if (line !== '') return line
}
return ''
}
var renderCard = function (entry, detail) {
var slug = entry.slug
var eyebrow = entry.campaign || 'Campaign digest'
var objective = String(entry.objective || '')
var blocked = firstLine(detail.blocked)
var chips = ''
var rows = (detail.verdicts || []).slice(1, 1 + MAX_VERDICT_CHIPS)
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
if (!row || row.length < 2) continue
var label = statusToken(row[row.length - 1])
if (label === '') continue
chips +=
'<span style="font-size:10px;font-weight:600;padding:1px 7px;border-radius:8px;border:1px solid var(--amc-border);color:' +
chipTone(row[row.length - 1]) +
';white-space:nowrap">' +
esc(row[0]) +
' \\u00b7 ' +
esc(label) +
'</span>'
}
el.innerHTML =
'<div data-card style="display:flex;flex-direction:column;gap:6px;min-width:0;height:100vh;border:1px solid var(--amc-border);border-radius:var(--amc-radius);background:var(--amc-layer);padding:var(--amc-pad-tile);cursor:pointer">' +
'<div style="font-size:10px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--amc-text-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
esc(eyebrow) +
'</div>' +
(objective !== ''
? '<div style="font-size:12px;color:var(--amc-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
esc(objective) +
'</div>'
: '') +
(chips !== '' ? '<div style="display:flex;gap:4px;flex-wrap:wrap;overflow:hidden">' + chips + '</div>' : '') +
'<div style="font-size:11px;color:' +
(blocked !== '' ? 'var(--amc-warning)' : 'var(--amc-text-faint)') +
';margin-top:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
(blocked !== '' ? '\\u26a0 needs you \\u2014 ' + esc(blocked) : 'nothing blocked') +
'</div>' +
'</div>'
var card = el.querySelector('[data-card]')
if (card)
card.onclick = function () {
amico.prompt('Open the campaign ' + slug)
}
}
var epoch = 0
var render = function () {
var mine = ++epoch
amico
.fetch('/amicode/campaigns')
.then(function (data) {
if (mine !== epoch) return undefined
if (!data || data.ok === false) return showEmpty()
var picked = pickCampaign(data.campaigns)
if (!picked || !picked.slug) return showEmpty()
return amico
.fetch('/amicode/campaign?slug=' + encodeURIComponent(picked.slug))
.then(function (detail) {
if (mine !== epoch) return undefined
if (!detail || detail.ok === false || !detail.campaign) return showEmpty()
renderCard(picked, detail.campaign)
})
})
.catch(function () {
if (mine === epoch) showEmpty()
})
}
render()
// Re-render on host config/theme pushes (the jump-back-in pattern);
// the epoch guard drops a stale fetch's late write.
amico.onConfig(render)
amico.onTheme(render)
},
}
`
28 changes: 27 additions & 1 deletion packages/extension/test/amicode_service_contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,30 @@ describe("amicode service — golden-fixture parity with the fork", () => {
return obj;
};

/** amicode#678: the port ships a campaign-digest BUILT-IN widget the fork
* pin predates (recorded at v1.18.10-amicode.18 with jump-back-in only).
* Dropped from BOTH sides so parity stays comparable across the next pin
* bump; the widget itself is unit-tested in widgets_registry.test.ts.
* Touches the three routes that project the registry: /amicode/widgets
* (top-level `widgets`) and the dashboard GET/POST (the merge appends
* every built-in as a default tile under `dashboard.widget`). */
const normalizeBuiltinWidgetDrift = (obj: any): any => {
if (obj && typeof obj === "object") {
const out = { ...obj };
if (Array.isArray(out.widgets)) {
out.widgets = out.widgets.filter((w: any) => !(w && w.id === "campaign-digest"));
}
if (out.dashboard && typeof out.dashboard === "object" && Array.isArray(out.dashboard.widget)) {
out.dashboard = {
...out.dashboard,
widget: out.dashboard.widget.filter((w: any) => !(w && w.id === "campaign-digest")),
};
}
return out;
}
return obj;
};

const normalizeWallClock = (obj: any): any => {
if (
obj &&
Expand Down Expand Up @@ -206,7 +230,9 @@ describe("amicode service — golden-fixture parity with the fork", () => {
// JSON routes: deep-equal on parsed bodies (key order is the port's
// business; structure and values are the contract).
const canon = (o: any, seededAt: number) =>
normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt)))));
normalizeBuiltinWidgetDrift(
normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))))),
);
expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt));
} else {
// Non-JSON routes (the served widget frame): byte-exact after sandbox
Expand Down
124 changes: 124 additions & 0 deletions packages/extension/test/widgets_registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// AMICODE (issue #678): the campaign-digest built-in widget — registration
// and route-contract pins. The widget's JS is a string-rendered module (the
// jump-back-in pattern), so behavioral assertions are string-level on the
// registry entry + the route bodies; the iframe itself is exercised by the
// runtime's own contract, not here.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadRegistry, widgetsResponse, widgetCodeResponse } from "../src/amicode_service/widgets";
import { manifestToml, widgetJs } from "../src/amicode_service/widgets_src/campaign-digest";

const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;

describe("campaign-digest built-in widget (issue #678)", () => {
let savedWidgetsDir: string | undefined;
let userDir: string;

beforeAll(() => {
// Hermetic registry: point the user-widgets root at an empty temp dir so
// host state (~/.amico/widgets) can't leak entries into the assertions.
savedWidgetsDir = process.env.AMICODE_WIDGETS_DIR;
userDir = mkdtempSync(join(tmpdir(), "amico-widgets-"));
process.env.AMICODE_WIDGETS_DIR = userDir;
});
afterAll(() => {
if (savedWidgetsDir === undefined) delete process.env.AMICODE_WIDGETS_DIR;
else process.env.AMICODE_WIDGETS_DIR = savedWidgetsDir;
rmSync(userDir, { recursive: true, force: true });
});

// AC1 — registration, manifest validity, default tile order
it("registers as a built-in after jump-back-in, with a valid tile manifest", () => {
const { widgets, warnings } = loadRegistry();
const jump = widgets.findIndex((w) => w.manifest.id === "jump-back-in");
const digest = widgets.findIndex((w) => w.manifest.id === "campaign-digest");
expect(jump).toBeGreaterThanOrEqual(0);
expect(digest).toBeGreaterThan(jump);
const entry = widgets[digest];
expect(entry.builtin).toBe(true);
expect(entry.manifest.size).toBe("tile");
expect(KEBAB.test(entry.manifest.id)).toBe(true);
expect(warnings.some((w) => w.id === "campaign-digest")).toBe(false);
});

it("manifest TOML carries the digest identity (parses into the registry)", () => {
expect(manifestToml).toContain('id = "campaign-digest"');
expect(manifestToml).toContain('size = "tile"');
expect(manifestToml).toMatch(/height = \d+/);
});

// AC2 — the route contract, string-level on the widget source
it("fetches the campaigns list then the campaign detail for the picked slug", () => {
expect(widgetJs).toContain("'/amicode/campaigns'");
expect(widgetJs).toContain("'/amicode/campaign?slug='");
expect(widgetJs).toContain("encodeURIComponent(");
});

it("picks the newest ACTIVE campaign with a newest-overall fallback", () => {
expect(widgetJs).toContain("pickCampaign");
expect(widgetJs).toContain("'active'"); // the ACTIVE status match
});

it("renders up to 3 verdict chips from the detail's verdict rows", () => {
expect(widgetJs).toContain("MAX_VERDICT_CHIPS = 3");
expect(widgetJs).toContain("verdicts");
});

it("renders the needs-you line from the blocked section", () => {
expect(widgetJs).toContain("needs you");
expect(widgetJs).toContain("nothing blocked");
});

it("degrades to the empty state when there are no campaigns", () => {
expect(widgetJs).toContain("showEmpty");
expect(widgetJs).toContain("el.innerHTML = ''");
});

// AC3 — fetch failure → empty state, never an error dump
it("catches fetch failures into the empty state", () => {
expect(widgetJs).toContain(".catch(");
// the catch path and the no-campaign path share the same empty state
expect(widgetJs).toContain("showEmpty");
});

// AC4 — click composes into chat with the slug
it("clicks through to amico.prompt with the campaign slug", () => {
expect(widgetJs).toContain("amico.prompt(");
expect(widgetJs).toContain("'Open the campaign '");
});

// AC5 — esc discipline + theme tokens only
it("escapes all interpolated HTML and themes via --amc-* custom properties only", () => {
expect(widgetJs).toContain("esc(");
expect(widgetJs).toContain("&amp;");
expect(widgetJs).toContain("&lt;");
expect(widgetJs).toContain("&gt;");
expect(widgetJs).toContain("&quot;");
expect(widgetJs).toContain("var(--amc-");
expect(widgetJs).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); // no raw hex colors
expect(widgetJs).not.toContain("rgb(");
expect(widgetJs).not.toContain("localStorage"); // opaque origin — it throws
});

// AC6 — registry/content-hash machinery consistency
it("hashes the entry stably across registry loads (idempotence)", () => {
const a = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest");
const b = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest");
expect(a).toBeDefined();
expect(b).toBeDefined();
expect(a!.hash).toMatch(/^[0-9a-f]{16}$/);
expect(a!.hash).toBe(b!.hash);
});

it("serves the widget through the /amicode/widgets + widget-code route bodies", () => {
const list = JSON.parse(widgetsResponse());
expect(list.ok).toBe(true);
expect(list.widgets.map((w: any) => w.id)).toContain("campaign-digest");
const code = JSON.parse(widgetCodeResponse("campaign-digest"));
expect(code.ok).toBe(true);
expect(code.hash).toMatch(/^[0-9a-f]{16}$/);
expect(code.code).toBe(widgetJs); // the served code IS the source string
});
});
Loading