From 8a476395914ca5884de56a9c0d1ae3f876931213 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 31 Aug 2026 18:41:30 -0400 Subject: [PATCH 1/3] feat(app): register the campaign named surface in the Work Column tab grammar (#694) createSessionTabs treats 'campaign' exactly like pulseInspector: a named surface (campaignOpen memo, excluded from panelTabs, activeTab fallback after pulseInspector, closable). The tab itself is the next slice; the grammar lands first so the deep link and the panel menu have something to open. --- packages/app-bundle/manifest.json | 4 +- .../app/src/pages/session/helpers.test.ts | 61 +++++++++++++++++++ .../packages/app/src/pages/session/helpers.ts | 9 ++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index a91e604d..e4b0a3e5 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1097,8 +1097,8 @@ "packages/app/src/pages/session/composer/session-tour.test.ts": "147473505d54466417b6b34fa672d280b71e1b9577e8bd41fbf9870035d1083e", "packages/app/src/pages/session/composer/session-tour.tsx": "da5d807c7efe7aed1140d65d7eddd7982a8c17348d928f5d0a887d1b2a8d3a50", "packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "70a8267c725af320d78d266b85fd0ff005d067c4d09a21bef824126602e831e7", - "packages/app/src/pages/session/helpers.test.ts": "a473d86117e3fddd25ba28a189d90fa35bcb80da7f2a152d3188e139f375e45d", - "packages/app/src/pages/session/helpers.ts": "8d0106a5ec3f01a666bd840e20b6bfb28d0e88b8c8c51fc1fdd7eaa33e9daafc", + "packages/app/src/pages/session/helpers.test.ts": "77d8c3100793686f40b5982187783d20fa883aa7a5df1fe7c0f01970a750d9c1", + "packages/app/src/pages/session/helpers.ts": "fc0d700f4ada39dab274dca9dcc8ff784349c2ad5fb0314017a1e42408d827b1", "packages/app/src/pages/session/session-panel-width.test.ts": "2b9daf379be0b54142dd791bed6bae6d215b2574176b3b3e4b1273db3baf433c", "packages/app/src/pages/session/spawn-tabs.test.ts": "b0eaeb976f3ae520fe38cd6b4cc1493b06c626923efaa2f57c22c70a638fb511", "packages/app/src/pages/session/spawn-tabs.ts": "e5ae126808831944199244f67e4475aeb8f31a21c9eeff70eb3cd1ec05f6c5da", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts index 64f77e59..b3275a39 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts @@ -330,4 +330,65 @@ describe("createSessionTabs", () => { dispose() }) }) + + // amicode#694: the Campaign tab — the Work Column's full-ledger drill-down — + // is a named surface exactly like pulseInspector: opened by the digest tile's + // deep link or the panel menu, closable, never a file tab. + test("campaign is a closable, reopenable named surface", () => { + // Step 1: campaign is open and active + const initial = { tabs: { all: ["campaign"], active: "campaign" as string | undefined }, preview: undefined } + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => initial.tabs.active, all: () => initial.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.activeTab()).toBe("campaign") + expect(result.campaignOpen()).toBe(true) + expect(result.closableTab()).toBe("campaign") + // a named surface never renders as a file tab in the strip + expect(result.panelTabs()).toEqual([]) + dispose() + }) + + // Step 2: close campaign — removed from state, active falls to home + const afterClose = closeSessionTab(initial, "campaign") + expect(afterClose.tabs.all).toEqual([]) + expect(afterClose.tabs.active).toBeUndefined() + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterClose.tabs.active, all: () => afterClose.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.campaignOpen()).toBe(false) + expect(result.activeTab()).toBe("home") + dispose() + }) + + // Step 3: re-open campaign (the digest tile's deep link does exactly this) + const afterReopen = openSessionTab( + { tabs: afterClose.tabs, preview: afterClose.preview }, + "campaign", + ) + expect(afterReopen.tabs.all).toContain("campaign") + expect(afterReopen.tabs.active).toBe("campaign") + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterReopen.tabs.active, all: () => afterReopen.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.campaignOpen()).toBe(true) + expect(result.activeTab()).toBe("campaign") + expect(result.closableTab()).toBe("campaign") + dispose() + }) + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts index c3104919..5936c079 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts @@ -45,6 +45,9 @@ export const createSessionTabs = (input: TabsInput) => { (input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)), ) const pulseInspectorOpen = createMemo(() => input.tabs().active() === "pulseInspector" || input.tabs().all().includes("pulseInspector")) + // amicode#694: the Campaign tab — the digest tile's full-ledger drill-down — + // is a named surface like pulseInspector, never a file tab. + const campaignOpen = createMemo(() => input.tabs().active() === "campaign" || input.tabs().all().includes("campaign")) const homeOpen = createMemo(() => input.tabs().active() === "home" || input.tabs().all().includes("home")) const panelTabs = createMemo( () => { @@ -53,7 +56,7 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review" || tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] + if (tab === "context" || tab === "review" || tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector" || tab === "campaign") return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] @@ -72,6 +75,7 @@ export const createSessionTabs = (input: TabsInput) => { if (active === "home") return active if (active === "context") return active if (active === "pulseInspector") return active + if (active === "campaign") return active if (active === SESSION_PREVIEW_TAB && previewOpen()) return active if (active === "vault" && vaultOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active @@ -84,6 +88,7 @@ export const createSessionTabs = (input: TabsInput) => { if (previewOpen()) return SESSION_PREVIEW_TAB if (contextOpen()) return "context" if (pulseInspectorOpen()) return "pulseInspector" + if (campaignOpen()) return "campaign" if (review() && hasReview()) return "review" return "home" }) @@ -96,6 +101,7 @@ export const createSessionTabs = (input: TabsInput) => { const active = activeTab() if (active === "context") return active if (active === "pulseInspector" && pulseInspectorOpen()) return active + if (active === "campaign" && campaignOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (!openedTabs().includes(active)) return return active @@ -105,6 +111,7 @@ export const createSessionTabs = (input: TabsInput) => { contextOpen, previewOpen, pulseInspectorOpen, + campaignOpen, homeOpen, openFileOpen, panelTabs, From a60b8366345ccf51c7d05407b1940b8b7475693f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 31 Aug 2026 18:48:37 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(app):=20the=20campaign=20ledger=20mode?= =?UTF-8?q?l=20=E2=80=94=20route=20payload=20=E2=86=92=20render=20model=20?= =?UTF-8?q?+=20the=20deep-link=20recognizer=20(#694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit campaign-tab-model.ts projects GET /amicode/campaigns + the per-campaign detail route into the tab's render model: verdict table (header detection by marker-free row 0; unit → first cell, status token + verbatim evidence → last cell per the §2 grammar), objective/blocked marker-stripped lines, §8 loop-log tail as table rows or text lines, frontmatter identity. Plus campaignPromptSlug (the digest tile's exact 'Open the campaign ' composition) and pickCampaign (the digest's newest-ACTIVE rule). --- packages/app-bundle/manifest.json | 2 + .../campaign/campaign-tab-model.test.ts | 162 ++++++++++++++ .../amicode/campaign/campaign-tab-model.ts | 207 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index e4b0a3e5..26496ffe 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -919,6 +919,8 @@ "packages/app/public/assets/JuliaMono-Regular.woff2": "43f5dfca02a03035f89799da5af150f28d5c4aff729753076094c687fc61b063", "packages/app/public/assets/RacingSansOne-Regular.woff2": "2ff92c8abe7a35962922c54da215d68690f8502463698b1b595bed597b63e3ff", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", + "packages/app/src/amicode/campaign/campaign-tab-model.ts": "4841be5aa39a36959e94216a052296a7ffdbd2ae4479149f36d8aaef2ee573cb", + "packages/app/src/amicode/campaign/campaign-tab-model.test.ts": "3903bfd9f99e5b279e6cb508f27b16f5983c5aaeff121e00f3b2847bc3a136c2", "packages/app/src/amicode/inspector/device-inspector.tsx": "5ab63709e88176bb4dad1072ffe28fd59ad67d414c9d844ee6c55353df50df79", "packages/app/src/amicode/inspector/inspector-bridge.ts": "460dbf4509b4e56a8eacd6cac7adcb3a63c6cd3e417d851df5f3e3b259095d3e", "packages/app/src/amicode/inspector/inspector-context.tsx": "ac81b9e0e2a4035f85b6af68b7f0cefcb9f256a175b244ba13136f3a307c0c77", diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts new file mode 100644 index 00000000..2f9b46dd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts @@ -0,0 +1,162 @@ +// amicode#694: the Campaign tab's data path — pure projections from the +// campaign routes (#662) to the tab's render model, plus the deep-link +// recognizer for the digest tile's click path. The routes' wire shapes are +// snake_case (campaign_ledger.ts); the model stays mechanical: strip display +// markers, split table cells, never invent content. +import { describe, expect, test } from "bun:test" +import { + campaignLedgerModel, + campaignPromptSlug, + pickCampaign, + statusTone, + type CampaignDetailPayload, +} from "./campaign-tab-model" + +const detail = (over: Partial = {}): CampaignDetailPayload => ({ + slug: "session-20260830-spin-cz", + date: "2026-08-30", + campaign: "exchange-CZ", + status: "ACTIVE", + type: "autoresearch", + // §1's body — the route strips the `## 1.` header line (splitSections) + objective: "- Land the exchange-CZ campaign\n- stay under 200 ns", + verdicts: [ + ["Unit", "Scope", "Status"], + ["H1 spec", "app overlay", "**DONE** — PR #17 merged"], + ["calibration", "chip", "**BLOCKED** — awaiting chip time"], + ], + active_work: "- refactoring the gate", + blocked: "- **chip time** — Shannon is booked until Thursday\n- probe rewire pending", + next_queue: "- sweep J coupling", + loop_log_tail: "| iter | verdict |\n| --- | --- |\n| 12 | spec landed |\n| 13 | blocked on chip |", + compaction: "(append-only: compaction log)", + sections_found: [1, 2, 3, 4, 5, 8], + file_date: "2026-08-30", + ...over, +}) + +describe("campaignPromptSlug — the digest tile's click-path deep link", () => { + test("recognizes the tile's exact composition", () => { + expect(campaignPromptSlug("Open the campaign session-20260830-spin-cz")).toBe("session-20260830-spin-cz") + }) + + test("is case-insensitive on the phrase, exact on the slug", () => { + expect(campaignPromptSlug("open the campaign session-x")).toBe("session-x") + }) + + test("rejects non-campaign prompts and empty text", () => { + expect(campaignPromptSlug("Open the problem foo")).toBeNull() + expect(campaignPromptSlug("")).toBeNull() + expect(campaignPromptSlug("open the campaign")).toBeNull() + }) + + test("rejects slug-unsafe tails (traversal, spaces)", () => { + expect(campaignPromptSlug("Open the campaign ../etc/passwd")).toBeNull() + expect(campaignPromptSlug("Open the campaign two words")).toBeNull() + }) +}) + +describe("pickCampaign — same rule as the digest tile (newest ACTIVE, newest fallback)", () => { + test("prefers the newest ACTIVE entry; the list is newest-first", () => { + const list = [ + { slug: "a", status: "FINISHED" }, + { slug: "b", status: "ACTIVE" }, + ] + expect(pickCampaign(list)?.slug).toBe("b") + }) + + test("falls back to the newest overall when none is ACTIVE", () => { + const list = [{ slug: "a", status: "FINISHED" }, { slug: "b", status: null }] + expect(pickCampaign(list)?.slug).toBe("a") + }) + + test("empty list picks nothing", () => { + expect(pickCampaign([])).toBeUndefined() + }) +}) + +describe("campaignLedgerModel — the full-ledger projection", () => { + test("maps the verdict table: header columns + unit/status/evidence rows", () => { + const model = campaignLedgerModel(detail()) + expect(model.verdictColumns).toEqual(["Unit", "Scope", "Status"]) + expect(model.verdicts).toEqual([ + { unit: "H1 spec", status: "DONE", evidence: "**DONE** — PR #17 merged" }, + { unit: "calibration", status: "BLOCKED", evidence: "**BLOCKED** — awaiting chip time" }, + ]) + }) + + test("projects the status token mechanically (bold stripped, cut at the dash)", () => { + const model = campaignLedgerModel(detail()) + expect(model.verdicts[0]?.status).toBe("DONE") + expect(model.verdicts[1]?.status).toBe("BLOCKED") + }) + + test("handles a verdict table without a header row", () => { + const model = campaignLedgerModel(detail({ verdicts: [["S1", "**DONE** — merged"]] })) + expect(model.verdictColumns).toEqual([]) + expect(model.verdicts).toEqual([{ unit: "S1", status: "DONE", evidence: "**DONE** — merged" }]) + }) + + test("renders the objective as marker-stripped lines", () => { + const model = campaignLedgerModel(detail()) + expect(model.objectiveLines).toEqual(["Land the exchange-CZ campaign", "stay under 200 ns"]) + }) + + test("renders the blocked queue with reasons as lines", () => { + const model = campaignLedgerModel(detail()) + expect(model.blockedLines).toEqual(["chip time — Shannon is booked until Thursday", "probe rewire pending"]) + }) + + test("keeps a table §8 loop-log tail as rows and a text tail as lines", () => { + const table = campaignLedgerModel(detail()) + expect(table.loopLog).toEqual({ kind: "table", rows: [["iter", "verdict"], ["12", "spec landed"], ["13", "blocked on chip"]] }) + const text = campaignLedgerModel(detail({ loop_log_tail: "loop 12 done\nloop 13 started" })) + expect(text.loopLog).toEqual({ kind: "text", lines: ["loop 12 done", "loop 13 started"] }) + }) + + test("surfaces the frontmatter identity fields", () => { + const model = campaignLedgerModel(detail()) + expect(model.slug).toBe("session-20260830-spin-cz") + expect(model.label).toBe("exchange-CZ") + expect(model.status).toBe("ACTIVE") + expect(model.date).toBe("2026-08-30") + }) + + test("falls back to the slug for the label and the filename date", () => { + const model = campaignLedgerModel(detail({ campaign: null, date: null })) + expect(model.label).toBe("session-20260830-spin-cz") + expect(model.date).toBe("2026-08-30") // file_date fallback + }) + + test("a null/undefined detail degrades to the empty model", () => { + const model = campaignLedgerModel(null) + expect(model.hasLedger).toBe(false) + expect(model.verdicts).toEqual([]) + expect(model.objectiveLines).toEqual([]) + }) + + test("a content-less ledger flags the empty state (the tab explains how one starts)", () => { + const model = campaignLedgerModel( + detail({ objective: "", verdicts: [], blocked: "", loop_log_tail: "" }), + ) + expect(model.hasLedger).toBe(false) + }) + + test("an objective-less ledger with content still counts as a ledger", () => { + const model = campaignLedgerModel(detail({ objective: "" })) + expect(model.hasLedger).toBe(true) + }) +}) + +describe("statusTone — the verdict chip tone discipline (theme tokens, no raw colors)", () => { + test("success / danger / neutral tones from the status token", () => { + expect(statusTone("DONE")).toBe("success") + expect(statusTone("MERGED")).toBe("success") + expect(statusTone("PASS")).toBe("success") + expect(statusTone("BLOCKED")).toBe("danger") + expect(statusTone("FAIL")).toBe("danger") + expect(statusTone("STUCK")).toBe("danger") + expect(statusTone("WIP")).toBe("neutral") + expect(statusTone("")).toBe("neutral") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.ts b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.ts new file mode 100644 index 00000000..b4076712 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.ts @@ -0,0 +1,207 @@ +// amicode#694: the Campaign tab's data path — a MECHANICAL projection of the +// campaign routes (#662: GET /amicode/campaigns, GET /amicode/campaign?slug=) +// into the tab's render model, plus the deep-link recognizer for the digest +// tile's click path (#690's widget composes `amico.prompt('Open the campaign ' +// + slug)`; the session recognizes that text and opens this tab beside it). +// +// Discipline (the #690 precedent): the model renders what the routes return — +// display compression only (marker stripping, table-cell splitting), no +// ledger-markdown re-parsing, no invented content. All strings are plain data; +// the component renders them through Solid's JSX (auto-escaped — no innerHTML). +// ── Wire shapes (snake_case, mirroring campaign_ledger.ts's route bodies) ──── + +/** One parsed markdown-table row: trimmed cell values (campaign_ledger.ts). */ +export type TableRow = string[] + +export interface CampaignSummary { + slug: string + date: string | null + campaign: string | null + status: string | null + type: string | null + objective: string +} + +/** GET /amicode/campaign?slug= — the full parsed ledger (campaign_ledger.ts). */ +export interface CampaignDetailPayload extends CampaignSummary { + /** §2's markdown-table rows; row 0 is the header when the table has one. */ + verdicts: TableRow[] + active_work: string + blocked: string + next_queue: string + loop_log_tail: string + compaction: string + sections_found: number[] + file_date: string | null +} + +// ── The deep-link recognizer (the digest tile's click path) ────────────────── + +/** The digest tile composes exactly `Open the campaign `. The slug is a + * session file stem (`session-…`): letters, digits, dash, underscore — the + * recognizer rejects anything that could traverse or smuggle markup. */ +const CAMPAIGN_PROMPT = /^open the campaign\s+([A-Za-z0-9_-]+)\s*$/i + +export function campaignPromptSlug(text: string): string | null { + const match = text.trim().match(CAMPAIGN_PROMPT) + return match ? (match[1] ?? null) : null +} + +// ── Display compression (the digest's mechanical projections, in TS) ───────── + +/** Strip list-bullet and bold markers from one ledger line. */ +function stripMarkers(line: string): string { + return line + .trim() + .replace(/^[-*]\s+/, "") + .split("**") + .join("") + .trim() +} + +/** Non-empty marker-stripped lines of a section body. */ +function sectionLines(body: string | undefined): string[] { + return String(body ?? "") + .split("\n") + .map(stripMarkers) + .filter((line) => line !== "") +} + +/** The status cell's leading token — ledgers write `**DONE** — PR #17 merged …` + * and the chip shows `DONE`. Same projection as the digest tile's chip label. */ +function statusToken(cell: string): string { + return String(cell ?? "") + .split("**") + .join("") + .split("—")[0] + ?.split("–")[0] + ?.trim() + .split(/\s+/) + .slice(0, 2) + .join(" ") ?? "" +} + +export type StatusTone = "success" | "danger" | "neutral" + +/** The verdict chip tone — token matching, theme-token classes in the component. */ +export function statusTone(cell: string): StatusTone { + const s = String(cell ?? "").toUpperCase() + if (s.includes("DONE") || s.includes("MERGED") || s.includes("PASS")) return "success" + if (s.includes("BLOCK") || s.includes("FAIL") || s.includes("STUCK")) return "danger" + return "neutral" +} + +// ── Campaign picking (the digest's rule, shared shape) ─────────────────────── + +/** Newest ACTIVE campaign; falls back to the newest overall (the list is + * newest-first per the route contract, so the first entry wins). */ +export function pickCampaign(campaigns: CampaignSummary[]): CampaignSummary | undefined { + if (campaigns.length === 0) return undefined + for (const entry of campaigns) { + if (String(entry.status ?? "").toLowerCase() === "active") return entry + } + return campaigns[0] +} + +// ── The ledger model ───────────────────────────────────────────────────────── + +export interface CampaignVerdict { + unit: string + status: string + /** The status cell verbatim — the ledger's evidence text, shown in full. */ + evidence: string +} + +export type LoopLog = + | { kind: "table"; rows: TableRow[] } + | { kind: "text"; lines: string[] } + +export interface CampaignLedgerModel { + slug: string + /** Frontmatter campaign (label fallback), then the slug. */ + label: string + status: string | null + date: string | null + objectiveLines: string[] + /** §2's header row, verbatim cells (empty when the table has none). */ + verdictColumns: string[] + verdicts: CampaignVerdict[] + blockedLines: string[] + loopLog: LoopLog + /** False for a null detail or a content-less ledger — the tab's + * how-one-starts empty state renders instead. */ + hasLedger: boolean +} + +const isTableLine = (line: string): boolean => /^\s*\|.*\|\s*$/.test(line) +const isSeparatorRow = (line: string): boolean => + /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line) + +function cells(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()) +} + +/** The §8 tail arrives either as re-joined table rows (`| a | b |`, the + * bounded window) or as plain lines. Keep the distinction — the tab renders + * a real table for the former, a log for the latter. */ +function loopLogModel(tail: string | undefined): LoopLog { + const lines = String(tail ?? "") + .split("\n") + .filter((line) => line.trim() !== "") + if (lines.length > 0 && lines.every(isTableLine)) { + return { kind: "table", rows: lines.filter((line) => !isSeparatorRow(line)).map(cells) } + } + return { kind: "text", lines: lines.map((line) => stripMarkers(line)) } +} + +export function campaignLedgerModel(payload: Partial | null | undefined): CampaignLedgerModel { + const detail = payload ?? {} + const verdictRows = Array.isArray(detail.verdicts) ? detail.verdicts : [] + // Row 0 is the header when the table has one. The wire can't say whether it + // does, so the detection is mechanical: a header's cells are bare column + // names — no bold markers, no em/en-dashes (data rows carry the grammar's + // `**DONE** — …` status cells). Mirrors the route contract's "header row + // first when the table has one". + const header = verdictRows[0] + const headerLike = + header !== undefined && header.length >= 2 && header.every((cell) => !/[*—–]/.test(cell)) + const verdictColumns = headerLike ? header : [] + const dataRows = verdictColumns.length > 0 ? verdictRows.slice(1) : verdictRows + const verdicts: CampaignVerdict[] = dataRows + .filter((row) => row.length > 0) + .map((row) => { + const unit = row[0] ?? "" + const evidence = row[row.length - 1] ?? "" + return { unit, status: statusToken(evidence), evidence } + }) + + const objectiveLines = sectionLines(detail.objective) + const blockedLines = sectionLines(detail.blocked) + const loopLog = loopLogModel(detail.loop_log_tail) + + const hasLedger = + objectiveLines.length > 0 || verdicts.length > 0 || blockedLines.length > 0 || loopLogContent(loopLog) > 0 + + const slug = String(detail.slug ?? "") + return { + slug, + label: detail.campaign || slug, + status: detail.status ?? null, + date: detail.date ?? detail.file_date ?? null, + objectiveLines, + verdictColumns, + verdicts, + blockedLines, + loopLog, + hasLedger: slug !== "" && hasLedger, + } +} + +function loopLogContent(log: LoopLog): number { + return log.kind === "table" ? log.rows.length : log.lines.length +} From 3024deac1892d357e6686c10438be1fa44cb255b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 31 Aug 2026 18:58:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(app):=20the=20Campaign=20tab=20?= =?UTF-8?q?=E2=80=94=20full-ledger=20surface=20+=20the=20digest=20tile's?= =?UTF-8?q?=20deep=20link=20(#694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CampaignTabContent renders the active campaign's complete ledger from the existing routes (#662): objective lines, the full verdict table (unit → status token → verbatim evidence), the blocked queue with reasons, and the §8 loop log (table or text). Empty state at every level — no campaigns explains how one starts (run a research loop; ledgers land in the personal vault's sessions/ dir), fetch failures are readable, a content-less ledger says so. Solid JSX only; theme-token classes. Integration: the campaign named surface gets its trigger in both tab-strip branches (legacy + v2), its Tabs.Content, and a panel-menu entry beside the quantum surfaces. The digest tile's click path deep-links in from both hosts: the Work Column's Home tab widget host recognizes 'Open the campaign ' and opens the tab (prompt still prefills the composer), and the home page's draft handoff opens it once per prompt text via the session prompt watch. --- packages/app-bundle/manifest.json | 6 +- .../campaign/campaign-tab-model.test.ts | 17 +- .../src/amicode/campaign/campaign-tab.test.ts | 74 ++++++ .../app/src/amicode/campaign/campaign-tab.tsx | 245 ++++++++++++++++++ .../src/pages/session/session-side-panel.tsx | 117 ++++++++- 5 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 26496ffe..24c411b6 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -920,7 +920,9 @@ "packages/app/public/assets/RacingSansOne-Regular.woff2": "2ff92c8abe7a35962922c54da215d68690f8502463698b1b595bed597b63e3ff", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", "packages/app/src/amicode/campaign/campaign-tab-model.ts": "4841be5aa39a36959e94216a052296a7ffdbd2ae4479149f36d8aaef2ee573cb", - "packages/app/src/amicode/campaign/campaign-tab-model.test.ts": "3903bfd9f99e5b279e6cb508f27b16f5983c5aaeff121e00f3b2847bc3a136c2", + "packages/app/src/amicode/campaign/campaign-tab.tsx": "7da4420dbabb34ad6e1b116b193b1daf3eb44458a7f14ff3f330f89a261921fa", + "packages/app/src/amicode/campaign/campaign-tab.test.ts": "b4ba7897615708bc17acacedc62db3ac29290b475db363deae64c31d166a310d", + "packages/app/src/amicode/campaign/campaign-tab-model.test.ts": "c6037af793de92b365564fbe4f2413a74dd350a4102bc795d36f4d73ccede541", "packages/app/src/amicode/inspector/device-inspector.tsx": "5ab63709e88176bb4dad1072ffe28fd59ad67d414c9d844ee6c55353df50df79", "packages/app/src/amicode/inspector/inspector-bridge.ts": "460dbf4509b4e56a8eacd6cac7adcb3a63c6cd3e417d851df5f3e3b259095d3e", "packages/app/src/amicode/inspector/inspector-context.tsx": "ac81b9e0e2a4035f85b6af68b7f0cefcb9f256a175b244ba13136f3a307c0c77", @@ -1106,7 +1108,7 @@ "packages/app/src/pages/session/spawn-tabs.ts": "e5ae126808831944199244f67e4475aeb8f31a21c9eeff70eb3cd1ec05f6c5da", "packages/app/src/pages/session/session-panel-width.ts": "8723cb2f980972ea9bf182240fdf154d131bf40e22fe563fd538a7005cff9192", "packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a", - "packages/app/src/pages/session/session-side-panel.tsx": "e2346a1f9d81c4f8436051ebf92dcd747be28f1e6b610d98792569067f28a4c0", + "packages/app/src/pages/session/session-side-panel.tsx": "6519eec611a7c2a67c33b2f6b5b0f25c06937f63979f2efc353f3a63de569e96", "packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0", "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b", "packages/app/src/pages/session/timeline/message-timeline.tsx": "7db5ccaf96ad37fbc0fa078f65966f3b34a6102ce92e31e4c912ef89e5ecd0ea", diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts index 2f9b46dd..97ca9291 100644 --- a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab-model.test.ts @@ -10,6 +10,7 @@ import { pickCampaign, statusTone, type CampaignDetailPayload, + type CampaignSummary, } from "./campaign-tab-model" const detail = (over: Partial = {}): CampaignDetailPayload => ({ @@ -57,16 +58,22 @@ describe("campaignPromptSlug — the digest tile's click-path deep link", () => }) describe("pickCampaign — same rule as the digest tile (newest ACTIVE, newest fallback)", () => { + const entry = (slug: string, status: string | null): CampaignSummary => ({ + slug, + date: null, + campaign: null, + status, + type: null, + objective: "", + }) + test("prefers the newest ACTIVE entry; the list is newest-first", () => { - const list = [ - { slug: "a", status: "FINISHED" }, - { slug: "b", status: "ACTIVE" }, - ] + const list = [entry("a", "FINISHED"), entry("b", "ACTIVE")] expect(pickCampaign(list)?.slug).toBe("b") }) test("falls back to the newest overall when none is ACTIVE", () => { - const list = [{ slug: "a", status: "FINISHED" }, { slug: "b", status: null }] + const list = [entry("a", "FINISHED"), entry("b", null)] expect(pickCampaign(list)?.slug).toBe("a") }) diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.test.ts b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.test.ts new file mode 100644 index 00000000..38e29dc3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.test.ts @@ -0,0 +1,74 @@ +// amicode#694: the Campaign tab — the Work Column's full-ledger drill-down. +// The component is Solid JSX (DOM-tested surfaces in this repo are the model +// tests; the #690 precedent pins source-rendered widget surfaces at the +// string level). These pins cover the component's contract (routes, sections, +// empty states, escaping discipline) and the session-side-panel integration +// (tab trigger/content, panel menu, the digest tile's deep link). +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const here = import.meta.dir +const componentSrc = readFileSync(join(here, "campaign-tab.tsx"), "utf8") +const panelSrc = readFileSync(join(here, "../../pages/session/session-side-panel.tsx"), "utf8") + +describe("CampaignTabContent — the full-ledger surface", () => { + test("fetches the campaigns list, then the detail for the picked slug (route contract #662)", () => { + expect(componentSrc).toContain('"/amicode/campaigns"') + expect(componentSrc).toContain("`/amicode/campaign?slug=${") + expect(componentSrc).toContain("encodeURIComponent(") + expect(componentSrc).toContain("pickCampaign(") + }) + + test("maps the detail through the ledger model — no client-side fabrication", () => { + expect(componentSrc).toContain("campaignLedgerModel(") + }) + + test("renders the four ledger sections: objective, verdict table, blocked queue, loop log", () => { + expect(componentSrc).toContain("Objective") + expect(componentSrc).toContain("Verdict") + expect(componentSrc).toContain("Blocked") + expect(componentSrc).toContain("Loop log") + }) + + test("empty state: no campaign explains how one starts", () => { + expect(componentSrc).toContain("No campaign ledger yet") + expect(componentSrc).toContain("Amico") + }) + + test("escaping discipline: Solid JSX only — no innerHTML, no dangerouslySetInnerHTML", () => { + expect(componentSrc).not.toContain("innerHTML") + expect(componentSrc).not.toContain("dangerouslySetInnerHTML") + }) + + test("detail fetch failure degrades to a readable message, never a blank tab", () => { + expect(componentSrc).toContain("couldn't load") + }) +}) + +describe("Work Column integration — the campaign named surface", () => { + test("the tab trigger exists in both tab-strip branches (legacy + v2)", () => { + expect(panelSrc.split('value="campaign"').length - 1).toBeGreaterThanOrEqual(4) // 2 triggers + 2 contents + }) + + test("the panel menu offers Campaign beside the quantum surfaces", () => { + expect(panelSrc).toContain('id: "campaign"') + expect(panelSrc).toContain('"checklist"') + }) + + test("the active campaign tab renders CampaignTabContent", () => { + expect(panelSrc).toContain('activeTab() === "campaign"') + expect(panelSrc).toContain("") + }) +}) + +describe("The digest tile's click path deep-links into the tab", () => { + test("the Home tab's widget host recognizes the tile's prompt and opens the tab", () => { + expect(panelSrc).toContain("campaignPromptSlug(") + expect(panelSrc).toContain('open("campaign")') + }) + + test("the home page's draft handoff deep-links too (prompt watch, once per prompt)", () => { + expect(panelSrc).toContain("campaignDeepLink") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.tsx b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.tsx new file mode 100644 index 00000000..ff8650f2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/campaign/campaign-tab.tsx @@ -0,0 +1,245 @@ +// amicode#694: the Campaign tab — the Work Column's full-ledger drill-down +// beside the campaign-digest home tile (#690). Renders the ENTIRE campaign +// ledger from the existing routes (#662): the objective, the complete verdict +// table (unit → status → evidence), the blocked queue with reasons, and the +// §8 loop log. Data path: GET /amicode/campaigns → pickCampaign (the digest's +// newest-ACTIVE rule) → GET /amicode/campaign?slug= → campaignLedgerModel. +// +// Discipline: Solid JSX only (text auto-escapes; raw HTML injection is out of +// contract), theme-token utility classes, an empty state for every level (no +// campaigns → how one starts; fetch failure → readable message; content-less +// ledger → says so). +import { For, Show, createMemo, createResource } from "solid-js" +import { useServer } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" +import { + campaignLedgerModel, + pickCampaign, + statusTone, + type CampaignDetailPayload, + type CampaignSummary, + type StatusTone, +} from "./campaign-tab-model" + +const toneClass: Record = { + success: "text-v2-state-fg-success border-v2-state-border-success", + danger: "text-v2-state-fg-danger border-v2-state-border-danger", + neutral: "text-text-weak border-border-weak-base", +} + +const SectionLabel = (props: { text: string; tone?: StatusTone; note?: string }) => ( +
+
{props.text}
+ +
{props.note}
+
+
+) + +const StatusChip = (props: { status: string }) => ( + + {props.status} + +) + +export function CampaignTabContent() { + const server = useServer() + + const [lists, { refetch }] = createResource( + () => server.current, + async (conn) => { + const raw = (await amicodeGet(conn, "/amicode/campaigns")) as + | { ok: boolean; campaigns?: CampaignSummary[] } + | undefined + return raw?.ok ? (raw.campaigns ?? []) : [] + }, + ) + + const picked = createMemo(() => pickCampaign(lists() ?? [])) + + const [detail] = createResource( + () => picked()?.slug, + async (slug) => { + const conn = server.current + if (!conn || !slug) return undefined + const raw = (await amicodeGet(conn, `/amicode/campaign?slug=${encodeURIComponent(slug)}`)) as + | { ok: boolean; campaign?: CampaignDetailPayload } + | undefined + return raw?.ok ? raw.campaign : undefined + }, + ) + + const model = createMemo(() => campaignLedgerModel(detail())) + + // The loop log's discriminated union, narrowed once — Show's keyed children + // get the narrowed shape (the §8 tail renders as a table or a text log). + const loopTable = createMemo(() => { + const log = model().loopLog + return log.kind === "table" && log.rows.length > 0 ? log.rows : undefined + }) + const loopText = createMemo(() => { + const log = model().loopLog + return log.kind === "text" && log.lines.length > 0 ? log.lines : undefined + }) + + return ( +
+ +
+ The campaign list couldn't load — the Amicode service may be restarting. +
+ +
+ } + > + 0} + fallback={ +
+
No campaign ledger yet
+
+ Start a research loop — ask Amico to run a campaign. Ledgers land in your personal vault's + sessions/ directory and appear here: the objective, the verdict table, what's blocked, and the + loop log. +
+
+ } + > +
+
{model().label}
+ + + + +
{model().date}
+
+ +
+ + + The ledger for {picked()?.slug ?? "this campaign"} couldn't load. + + } + > + +
+ This campaign's ledger has no sections yet — they fill in as the loop runs. +
+ + } + > +
+ 0}> +
+ + + {(line) =>
{line}
} +
+
+
+ + 0}> +
+ +
+ + {(verdict, index) => ( +
0, + }} + > +
{verdict.unit}
+
+ +
+
{verdict.evidence}
+
+ )} +
+
+
+
+ +
+ + 0} + fallback={
Nothing blocked
} + > + + {(line) => ( +
+ + {line} +
+ )} +
+
+
+ +
+ + No loop entries yet
} + > + {(lines) => ( +
+ + {(line) =>
{line}
} +
+
+ )} + + } + > + {(rows) => ( +
+ + {(row, index) => ( +
0 }} + > + {(cell) =>
{cell}
}
+
+ )} +
+
+ )} + +
+ +
+
+
+ + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index 90e9f97e..806d655c 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -32,6 +32,8 @@ import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" import { RunInspector } from "@/amicode/inspector/run-inspector" import { useInspectorBridge } from "@/amicode/inspector/inspector-context" +import { CampaignTabContent } from "@/amicode/campaign/campaign-tab" +import { campaignPromptSlug } from "@/amicode/campaign/campaign-tab-model" import { WidgetGrid, parseWidgetsResponse, @@ -59,6 +61,7 @@ import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" @@ -145,6 +148,8 @@ function HomeTabContent() { const sdk = useSDK() const params = useParams() const navigate = useNavigate() + const { tabs } = useSessionLayout() + const prompt = usePrompt() // Compute the most recent non-empty session that isn't the current one const resumeSession = createMemo(() => { @@ -173,7 +178,17 @@ function HomeTabContent() { } return { ok: true } }, - prompt: () => {}, + // amicode#694: the campaign-digest tile composes `amico.prompt('Open the + // campaign ')` — its click path deep-links into the Campaign tab + // (the readable ledger beside this tile) and still lands in the composer + // so the agent sees the same ask. + prompt: (text) => { + if (campaignPromptSlug(text) !== null) { + tabs().open("campaign") + tabs().setActive("campaign") + } + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + }, open: () => {}, } @@ -400,12 +415,31 @@ export function SessionSidePanel(props: { const contextOpen = tabState.contextOpen const previewOpen = tabState.previewOpen const pulseInspectorOpen = tabState.pulseInspectorOpen + const campaignOpen = tabState.campaignOpen const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab + // amicode#694: the digest tile's home-page click path composes its prompt + // into a NEW session draft; when that draft lands here the campaign prompt + // opens the Campaign tab once per prompt text, so the readable ledger sits + // beside the chat. The Work Column's own Home tab deep-links directly in its + // widget host (see HomeTabContent's prompt callback). + const prompt = usePrompt() + const campaignDeepLink = new Map() + createEffect(() => { + const key = sessionKey() + const text = prompt.current().find((part) => part.type === "text")?.content ?? "" + if (campaignPromptSlug(text) === null) return + if (campaignDeepLink.get(key) === text) return + campaignDeepLink.set(key, text) + openReviewPanel() + if (!tabState.campaignOpen()) tabs().open("campaign") + tabs().setActive("campaign") + }) + const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTabValue = (value: string) => { @@ -487,6 +521,13 @@ export function SessionSidePanel(props: { active: () => activeTab() === "pulseInspector", group: "Quantum", }, + { + id: "campaign", + label: "Campaign", + icon: "checklist", + available: () => true, + active: () => activeTab() === "campaign", + }, { id: SESSION_PREVIEW_TAB, label: "Preview", @@ -714,6 +755,34 @@ export function SessionSidePanel(props: { +
+ + tabs().close("campaign")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("campaign")} + > +
+ +
Campaign
+
+
+
+ + + + + +
@@ -994,6 +1069,40 @@ export function SessionSidePanel(props: {
+
+ + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("campaign")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("campaign")} + > +
+ +
Campaign
+
+
+
+ + + + + +