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
4 changes: 4 additions & 0 deletions .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,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
Expand Down
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"check:boot-shell": "node --experimental-strip-types qa/boot-shell-contract.mjs",
"og-image": "node scripts/og-image.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"
},
Expand Down
64 changes: 64 additions & 0 deletions apps/website/scripts/queen-card-chat.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
75 changes: 75 additions & 0 deletions apps/website/src/lib/queenCardChat.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions apps/website/src/pages/Queen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { QueenSpecs } from "../components/QueenSpecs";
import { QueenAgents } from "../components/QueenAgents";
import { SELECTION_KEY, isExplorerTab, leaveTabSelections } from "../lib/queenEmbed";
import { specsChatSpec } from "../lib/queenSpecsChat";
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";
Expand Down Expand Up @@ -2624,6 +2625,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
Expand Down Expand Up @@ -3020,6 +3027,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 }}
Expand Down
Loading