From 91e3659109f7abf95a646c449e9d1e2cca5db2cd Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Thu, 1 Oct 2026 18:40:16 +0700 Subject: [PATCH] feat(queen): a card tap opens its agent chat in the app framing the board Framed by app.t27.ai/game on the same origin, a tap on a kanban card (board and map views) posts {v:1, type:'t27-app', kind:'open-card', repo, number, title, column} to the parent instead of following the GitHub link; the player opens the card's conversation with the agent there. Anywhere else -- t27.ai/#/queen, a cross-origin frame, the board alone -- and on cmd-click or middle click, the issue opens as before. Spec of record: gHashTag/t27 specs/automation/kanban-card-chat.t27 (t27#5389, EPIC t27#5388). Test: npm run test:card-chat, wired into website-checks. Reaches prod only with a QUEEN_REF bump in 999 -- owner's call; until then the player catches the anchor tap itself. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/website-checks.yml | 4 + apps/website/package.json | 1 + apps/website/scripts/queen-card-chat.test.mjs | 64 ++++++++++++++++ apps/website/src/lib/queenCardChat.ts | 75 +++++++++++++++++++ apps/website/src/pages/Queen.tsx | 10 +++ 5 files changed, 154 insertions(+) create mode 100644 apps/website/scripts/queen-card-chat.test.mjs create mode 100644 apps/website/src/lib/queenCardChat.ts diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 1c82cb05da..bafb1773df 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -243,6 +243,10 @@ jobs: # still refuse a spec whose own tests do not hold. - name: Viewport spec run: npm run check:viewport && npm run test:viewport-spec + # A card tap posts open-card to the app framing the board on its own + # origin, and nowhere else (specs/automation/kanban-card-chat.t27 in t27). + - name: Card opens its chat + run: npm run test:card-chat # The agent-facing surface (/agents.t27 and /llms.txt) is generated from # specs/catalog/onboarding.t27 through the compiler, and the generator # re-compiles what it is about to publish. This step is also what keeps diff --git a/apps/website/package.json b/apps/website/package.json index 281c669437..4a12648d3a 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -101,6 +101,7 @@ "test:wars-spec": "node --test scripts/queen-wars-from-spec.test.mjs", "check:roadmap-game": "node --experimental-strip-types qa/roadmap-game-contract.mjs", "test:viewport-spec": "node --test scripts/viewport-from-spec.test.mjs", + "test:card-chat": "node --experimental-strip-types --test scripts/queen-card-chat.test.mjs", "check:explorer-viewport": "node qa/explorer-viewport-contract.mjs", "check:passport": "node --experimental-strip-types qa/passport-figures.mjs" }, diff --git a/apps/website/scripts/queen-card-chat.test.mjs b/apps/website/scripts/queen-card-chat.test.mjs new file mode 100644 index 0000000000..82c9248455 --- /dev/null +++ b/apps/website/scripts/queen-card-chat.test.mjs @@ -0,0 +1,64 @@ +// A card tap posts open-card to the app framing the board, and nothing else does. +// Run: node --experimental-strip-types --test scripts/queen-card-chat.test.mjs +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { OPEN_CARD, openCardInApp, openCardMessage, sameOriginParent } from '../src/lib/queenCardChat.ts' + +const APP = 'https://app.t27.ai' + +function tap(extra = {}) { + const t = { defaultPrevented: false, button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, prevented: 0 } + t.preventDefault = () => { t.prevented += 1 } + return Object.assign(t, extra) +} + +function framed(parentOrigin) { + const sent = [] + const parent = { + get location() { + if (parentOrigin !== APP) throw new DOMException('Blocked a frame', 'SecurityError') + return { origin: parentOrigin } + }, + postMessage: (m, origin) => sent.push({ m, origin }), + } + return { win: { parent, location: { origin: APP } }, sent } +} + +const msg = openCardMessage('gHashTag/trinity', 7, 'Ship the sheet', 'running') + +test('the envelope is the one the player reads (spec MSG_V, MSG_TYPE, MSG_KIND)', () => { + assert.deepEqual(OPEN_CARD, { v: 1, type: 't27-app', kind: 'open-card' }) + assert.deepEqual(msg, { v: 1, type: 't27-app', kind: 'open-card', repo: 'gHashTag/trinity', number: 7, title: 'Ship the sheet', column: 'running' }) +}) + +test('framed by the app on the same origin: the tap opens the chat, not GitHub', () => { + const { win, sent } = framed(APP) + const t = tap() + assert.equal(openCardInApp(t, msg, win), true) + assert.equal(t.prevented, 1) + assert.deepEqual(sent, [{ m: msg, origin: APP }]) +}) + +test('framed by another origin, or not framed: the link is followed', () => { + const other = framed('https://t27.ai') + const t = tap() + assert.equal(openCardInApp(t, msg, other.win), false) + assert.equal(t.prevented, 0) + assert.equal(other.sent.length, 0) + + const alone = { location: { origin: APP } } + alone.parent = alone + assert.equal(openCardInApp(tap(), msg, alone), false) + assert.equal(sameOriginParent(alone), null) + assert.equal(openCardInApp(tap(), msg, undefined), false) +}) + +test('cmd-click, middle click and an already handled click keep the link', () => { + const { win, sent } = framed(APP) + for (const extra of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { defaultPrevented: true }]) { + const t = tap(extra) + assert.equal(openCardInApp(t, msg, win), false, JSON.stringify(extra)) + assert.equal(t.prevented, 0) + } + assert.equal(sent.length, 0) +}) diff --git a/apps/website/src/lib/queenCardChat.ts b/apps/website/src/lib/queenCardChat.ts new file mode 100644 index 0000000000..e798c9792a --- /dev/null +++ b/apps/website/src/lib/queenCardChat.ts @@ -0,0 +1,75 @@ +// A card on the board opens its conversation with the agent, in the app that frames it. +// +// Owner, 2026-10-01: tap any card on app.t27.ai/game/kanban and a chat with the +// agent opens, with the task's history, so any task can be written to and carried +// on. The chat lives in the player (999 apps/vibee-editor/player, the Hive's card +// sheet); the board only says which card was tapped. Spec of record: gHashTag/t27 +// specs/automation/kanban-card-chat.t27 (MSG_V, MSG_TYPE, MSG_KIND, SAME_ORIGIN_ONLY). +// +// The message goes to the parent only when the parent is this page's own origin -- +// the board served at https://app.t27.ai/queen/ inside the player's /game. Reading +// the parent's origin throws across origins, so t27.ai/#/queen, a page that frames +// the board elsewhere, and the board on its own all keep the card's GitHub link. +// A modified click, a middle click and a click something else already handled keep +// it too: cmd-click still opens the issue in a new tab. +// +// It imports nothing, so the node test can load it with --experimental-strip-types. + +export const OPEN_CARD = { v: 1, type: 't27-app', kind: 'open-card' } as const + +export interface OpenCardMessage { + v: 1 + type: 't27-app' + kind: 'open-card' + repo: string + number: number + title: string + column: string +} + +export function openCardMessage(repo: string, number: number, title: string, column: string): OpenCardMessage { + return { ...OPEN_CARD, repo, number, title, column } +} + +/** The part of a click this module reads; a React MouseEvent fits. */ +export interface CardTap { + defaultPrevented: boolean + button: number + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean + preventDefault(): void +} + +/** The part of `window` this module reads. */ +export interface CardWindow { + parent: unknown + location: { origin: string } +} + +/** The parent window when it is this page's own origin, else null. */ +export function sameOriginParent(win: CardWindow): { postMessage(m: unknown, origin: string): void } | null { + const parent = win.parent as { location?: { origin?: string }; postMessage?: unknown } | null + if (!parent || parent === win) return null + try { + if (parent.location?.origin !== win.location.origin) return null + } catch { + return null + } + return typeof parent.postMessage === 'function' ? (parent as { postMessage(m: unknown, origin: string): void }) : null +} + +/** + * The card's anchor onClick. True when the app took the tap -- the link is not + * followed and the app opens the card's chat; false leaves the link to the browser. + */ +export function openCardInApp(event: CardTap, message: OpenCardMessage, win: CardWindow | undefined = typeof window === 'undefined' ? undefined : window): boolean { + if (!win || event.defaultPrevented || event.button !== 0) return false + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + const parent = sameOriginParent(win) + if (!parent) return false + event.preventDefault() + parent.postMessage(message, win.location.origin) + return true +} diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx index 09840c20f9..727485ef68 100644 --- a/apps/website/src/pages/Queen.tsx +++ b/apps/website/src/pages/Queen.tsx @@ -12,6 +12,7 @@ import { Link, useSearchParams } from "react-router-dom"; import { QueenSpecs } from "../components/QueenSpecs"; import { QueenAgents } from "../components/QueenAgents"; import { SELECTION_KEY, isExplorerTab } from "../lib/queenEmbed"; +import { openCardInApp, openCardMessage } from "../lib/queenCardChat"; import { hiveFeedHealth, hiveDisplayRecords, hiveSameRepositorySnapshot, placeHiveDisplays, type HiveDisplay } from "../components/queenHiveDisplay"; import { QueenComb } from "../components/QueenComb"; import { QueenCommandPanel, type CommandItem } from "../components/QueenCommand"; @@ -2518,6 +2519,12 @@ function KanbanView({ href={`https://github.com/${repo}/issues/${card.number}`} target="_blank" rel="noreferrer" + // Framed by the app on its own origin, the tap opens the + // card's conversation with the agent there; anywhere else, + // and on cmd-click, the issue opens as before. + onClick={(event) => { + if (repo) openCardInApp(event, openCardMessage(repo, card.number, publicIssueTitle(card.title, card.number, lang), column.key)); + }} // The title is clamped to three lines in a 126px column — // measured, one card's title was nine lines and 147px of a // 222px card. Clamping without this would be losing the @@ -2824,6 +2831,9 @@ function MissionMapView({ href={`https://github.com/${repo}/issues/${card.number}`} target="_blank" rel="noreferrer" + onClick={(event) => { + if (repo) openCardInApp(event, openCardMessage(repo, card.number, publicIssueTitle(card.title, card.number, lang), column.key)); + }} key={card.number} layout initial={{ opacity: 0, scale: 0.8 }}