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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

export type {
CardDraft
} from "./models.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* CardDraft is one to-be-created card parsed from a valid create-cards
* reply -- exactly what the review surface previews.
*/
export interface CardDraft {
"title": string;
"kind"?: string;
"note"?: string;
"summary"?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ export function CardContextBlock(cardID: string, withAttachments: boolean): $Can
return $Call.ByID(4150547692, cardID, withAttachments);
}

/**
* CardContextEnvelope renders a card as the OUT envelope (goal 0099):
* its data as items, the reply contract inline. The plain-text
* CardContextBlock stays for human destinations; this is the
* machine-readable twin an external AI answers against.
*/
export function CardContextEnvelope(cardID: string): $CancellablePromise<string> {
return $Call.ByID(1724710751, cardID);
}

/**
* Cards returns every LIVE card (goal 0093: a tombstoned card is
* excluded, and a live child of a tombstoned container carries its
Expand Down Expand Up @@ -99,6 +109,15 @@ export function ConvertHTMLToMarkdown(html: string): $CancellablePromise<string>
return $Call.ByID(989919804, html);
}

/**
* CorrectionEnvelope re-emits the reply contract for the
* re-ask-the-source loop: validation problems and declined titles ride
* the instruction line, the schema stays the instruction.
*/
export function CorrectionEnvelope(problems: string[] | null, declinedTitles: string[] | null): $CancellablePromise<string> {
return $Call.ByID(4009289398, problems, declinedTitles);
}

/**
* CreateCard makes a new Card of kindID, optionally inside parentID
* ("" for root-level). A non-empty parentID must name an existing
Expand Down Expand Up @@ -425,6 +444,16 @@ export function PickFolder(startDir: string): $CancellablePromise<string> {
return $Call.ByID(3623587391, startDir);
}

/**
* PreviewClipbridgeReply validates a raw clipboard string against the
* reply contract (schema-first, then per-action requirements) and
* annotates it with collision state. Malformed input renders inline --
* this returns a Go error only for internal faults.
*/
export function PreviewClipbridgeReply(raw: string): $CancellablePromise<$models.ClipbridgeReplyPreview> {
return $Call.ByID(3851574839, raw);
}

/**
* PromoteNote is the note's one-way lifecycle event (the LOCKED
* design's "promotion ritual"): it becomes a typed Card in place --
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export {
export type {
AtlasImportSummary,
AtlasSessionState,
ClipbridgeCardOffer,
ClipbridgeReplyPreview,
FileDropCreateResult,
FileDropRoute,
FolderImportSummary,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as atlas$0 from "../../domain/atlas/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as clipbridge$0 from "../../domain/clipbridge/models.js";

/**
* AtlasImportSummary counts what ImportAtlas did, per family -- the
Expand Down Expand Up @@ -47,6 +50,34 @@ export interface AtlasSessionState {
"activePerspectiveID": string;
}

/**
* ClipbridgeCardOffer is one to-be-created card row on the review
* surface: the parsed draft plus its collision flag (the dedupe
* convention -- colliding rows default unchecked).
*/
export interface ClipbridgeCardOffer {
"Draft": clipbridge$0.CardDraft;
"CollidesWithID": string;
"CollidesWithKind": string;
}

/**
* ClipbridgeReplyPreview is what the Quick Panel renders when the
* clipboard carries a mill reply: the domain preview plus the
* Atlas-side collision annotations and the route workflow to run on
* accept.
*/
export interface ClipbridgeReplyPreview {
"Recognized": boolean;
"Valid": boolean;
"Action": string;
"Errors": string[] | null;
"Cards": ClipbridgeCardOffer[] | null;
"NoteTexts": string[] | null;
"RouteWorkflowID": string;
"RouteLabel": string;
}

/**
* FileDropCreateResult is CreateCardFromFileDrop's own response --
* wraps the newly created card with an additive duplicate-detection
Expand Down
107 changes: 107 additions & 0 deletions frontend/e2e/clipboard-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { test, expect } from './fixtures/server'
import { withClipboardLock } from './fixtures/clipboardLock'
import { noteCard, openCard } from './fixtures/atlasBoard'
import { deleteViaPageMenu } from './fixtures/atlasPage'

// The clipboard bridge (goal 0099): Copy for AI emits the JSON
// envelope; the Quick Panel's clipboard door recognizes a reply,
// renders the review surface (collisions default-unchecked), and the
// accept runs the seeded route workflow. Shared worker pool: every
// entity this file creates it deletes; clipboard sections take the
// cross-process lock per testing.md's real-pasteboard discipline.

async function openPanelWithClipboard(page: import('@playwright/test').Page, payload: string) {
await page.goto('about:blank')
await page.goto('/#/quickpanel')
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.evaluate((t) => navigator.clipboard.writeText(t), payload)
const search = page.getByRole('combobox', { name: 'Quick Panel search' })
await expect(search).toBeFocused()
await search.fill('apply from clipboard')
const option = page.getByRole('option', { name: 'Apply from clipboard…' })
await expect(option).toBeVisible()
await option.click()
}

test('Copy for AI puts the reply-contract envelope on the clipboard', async ({ page }) => {
await withClipboardLock(async () => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])

const card = noteCard(page, 'Getting started')
await openCard(page, card)
await page.getByTestId('atlas-overlay-copy-for-ai').click()

// The copy handler's binding round-trip + clipboard write are
// async relative to the click -- poll until the envelope lands.
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mill"')
const raw = await page.evaluate(() => navigator.clipboard.readText())
const envelope = JSON.parse(raw)
expect(envelope.mill).toBe(1)
expect(envelope.kind).toBe('context')
expect(envelope.schema.type).toBe('object')
expect(envelope.allowedActions).toContain('create-cards')
expect(envelope.items[0].title).toBe('Getting started')
expect(envelope.instructions).toContain('JSON code block')
await page.keyboard.press('Escape')
})
})

test('a valid reply reviews with collisions unchecked, and accepting creates only the checked card', async ({ page }) => {
const freshTitle = 'ZzE2eBridgeCard'
await withClipboardLock(async () => {
const reply = JSON.stringify({
mill: 1, kind: 'reply', action: 'create-cards',
items: [{ title: 'Getting started' }, { title: freshTitle, note: 'from the reply' }],
})
await openPanelWithClipboard(page, reply)

const review = page.getByTestId('quick-panel-reply-review')
await expect(review).toBeVisible()
const checkboxes = review.getByTestId('quick-panel-reply-card-checkbox')
await expect(checkboxes).toHaveCount(2)
await expect(checkboxes.nth(0)).not.toBeChecked()
await expect(checkboxes.nth(1)).toBeChecked()
await expect(review.getByText(/Already exists as/)).toBeVisible()

const confirm = review.getByTestId('quick-panel-reply-confirm')
await expect(confirm).toContainText('Create 1 card')
await confirm.click()
await expect(review).toHaveCount(0)
})

// The accepted card exists; the declined collision stayed singular.
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
const created = noteCard(page, freshTitle)
await expect(created).toBeVisible()
await expect(noteCard(page, 'Getting started')).toHaveCount(1)

// Cleanup (within-file discipline).
await openCard(page, created)
const overlay = page.locator('[data-component="atlas-card-overlay"]')
await deleteViaPageMenu(page, overlay)
await expect(created).toHaveCount(0)
})

test('an invalid reply names its failures and Copy corrected context re-emits the contract', async ({ page }) => {
await withClipboardLock(async () => {
const bad = JSON.stringify({ mill: 1, kind: 'reply', action: 'create-cards', items: [{ note: 'no title here' }] })
await openPanelWithClipboard(page, bad)

const invalid = page.getByTestId('quick-panel-reply-invalid')
await expect(invalid).toBeVisible()
await expect(invalid).toContainText('title')

await invalid.getByTestId('quick-panel-reply-copy-correction').click()
await expect(invalid.getByTestId('quick-panel-reply-copy-correction')).toContainText('Copied')
const raw = await page.evaluate(() => navigator.clipboard.readText())
const envelope = JSON.parse(raw)
expect(envelope.kind).toBe('context')
expect(envelope.instructions).toContain('did not validate')
expect(envelope.schema.properties.action.enum).toContain('create-cards')
})
})
5 changes: 3 additions & 2 deletions frontend/e2e/composition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ test('Composition page lists built-in workflows; node primitives live in a colla
// integration, goal 0066, ADR-0035/0038) + apply-backup-snapshot
// (goal 0065's data-stewardship backup step) + apply-list-row (the
// Lists write path, goal 0070) + apply-file-move (file verbs, goal
// 0087).
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(40)
// 0087) + apply-atlas-from-reply (the clipboard bridge's accepted-
// reply materializer, goal 0099).
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(41)
})

test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => {
Expand Down
6 changes: 4 additions & 2 deletions frontend/e2e/node-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ test('palette search matches both the shortened display name and the full underl
// RegisterNodeType call sites + the seeded "Check httpbin" declared
// step type, goal 0054 slice A).
await search.fill('')
await expect(panel.getByTestId('palette-item')).toHaveCount(40)
await expect(panel.getByTestId('palette-item')).toHaveCount(41)
})

// Progressive-disclosure "Show advanced steps" toggle (goal 0047): the
Expand All @@ -108,7 +108,7 @@ test('the palette shows every step by default, "Show advanced steps" checked', a
await openPaletteOnNewWorkflow(page)
const panel = activePanel(page)
await expect(panel.getByTestId('palette-show-advanced')).toBeChecked()
await expect(panel.getByTestId('palette-item')).toHaveCount(40)
await expect(panel.getByTestId('palette-item')).toHaveCount(41)
})

test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, and persists across a reload', async ({ page }) => {
Expand All @@ -130,6 +130,8 @@ test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, a
'process-atlas-card-find', 'apply-atlas-card-create', 'apply-atlas-card-update',
// goal 0070: fieldBindings is the same hand-authored JSON shape.
'apply-list-row',
// goal 0099: consumes a JSON items array from an attribute.
'apply-atlas-from-reply',
]
for (const id of advancedIDs) {
await expect(panel.locator(`[data-node-type-id="${id}"]`)).toHaveCount(0)
Expand Down
58 changes: 22 additions & 36 deletions frontend/src/app/QuickPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { Events } from '@wailsio/runtime'
import { Text } from '@primer/react'
import { FilteredActionList } from '@primer/react/experimental'
import { NoteIcon, PlayIcon } from '@primer/octicons-react'
import { AtlasService, CompositionService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings'
import type { ClipboardApplyPreview } from '../shared/bindings'
import { AtlasService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings'
import { generateSamplePayload } from '../shared/configSchema'
import { useAppStore, refreshWorkflows, refreshRequests, refreshKeybindings } from '../shared/store'
import {
Expand All @@ -19,6 +18,8 @@ import { buildConfigureAndActionEntries } from './quickPanelActionEntries'
import type { PanelEntry } from './quickPanelActionEntries'
import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture'
import { QuickPanelClipboardApply } from './QuickPanelClipboardApply'
import { QuickPanelReplyReview } from './QuickPanelReplyReview'
import { useQuickPanelClipboardDoor } from './useQuickPanelClipboardDoor'
import { FacetChipRow } from '../shared/FacetChipRow'
import { useQuickPanelFacetSearch } from './quickPanelFacets'
import styles from './QuickPanel.module.css'
Expand Down Expand Up @@ -125,14 +126,6 @@ export function QuickPanel() {
// those; duplicating them per-window would double-fire OS
// notifications for the same pending item).
const [reviewPendingCount, setReviewPendingCount] = useState(0)
// docs/goals/0039: non-null swaps the panel body from the search list
// into QuickPanelClipboardApply's preview-confirm view. json is the
// exact clipboard text the preview was computed from -- re-sent to
// ConfirmClipboardApply on confirm rather than re-read from the
// clipboard a second time (the user's gesture already captured it
// once; a second OS-level read has no reason to differ and would
// just be a second permission prompt).
const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null)
const inputRef = useRef<HTMLInputElement>(null)

// Declared before the effects that reference them (react-hooks/
Expand Down Expand Up @@ -318,32 +311,9 @@ export function QuickPanel() {
})
}

// docs/goals/0039: reads the clipboard on the row's own click/Enter
// (the user gesture the Clipboard API requires) and hands the raw
// text to PreviewClipboardApply -- checked what exists first: the
// clipboard adapter (internal/adapters/clipboard) is wired for
// workflow-EXECUTION-side capture/apply nodes, not exposed as a
// general read-text RPC, and this window is an ordinary Wails webview
// where navigator.clipboard.readText() already works. Never throws
// through to the caller -- every failure path (permission denied,
// empty clipboard, malformed/unrecognized payload) becomes a
// Recognized=false preview so QuickPanelClipboardApply's own error
// view renders it, same as a genuinely bad payload would.
const applyFromClipboard = () => {
navigator.clipboard.readText()
.then((text) => {
if (!text.trim()) {
setClipboardApply({ json: text, preview: { recognized: false, error: t('quickPanel.clipboard.emptyError') } })
return
}
CompositionService.PreviewClipboardApply(text)
.then((preview) => setClipboardApply({ json: text, preview }))
.catch((err) => setClipboardApply({ json: text, preview: { recognized: false, error: String(err) } }))
})
.catch((err) => {
setClipboardApply({ json: '', preview: { recognized: false, error: t('quickPanel.clipboard.readError', { error: String(err) }) } })
})
}
// The clipboard door (goals 0039 + 0099) lives in its own hook --
// one row recognizes both a workflow export and a mill reply.
const { clipboardApply, setClipboardApply, replyReview, setReplyReview, applyFromClipboard } = useQuickPanelClipboardDoor(t)

// The away-capture door (docs/goals/0090): a typed query with no
// intent to search becomes a Note instead, filed into the Scratchpad
Expand Down Expand Up @@ -452,6 +422,22 @@ export function QuickPanel() {
// (ADR-0033) has no room for a second, nested surface, so this is a
// full replacement, not an overlay. Cancel/Applied both clear the
// state, returning to the ordinary search list.
if (replyReview) {
return (
<div className={styles.panel} data-testid="quick-panel">
<QuickPanelReplyReview
preview={replyReview}
onCancel={() => setReplyReview(null)}
onApplied={(label) => {
setReplyReview(null)
setStatus(t('quickPanel.status.replyApplied', { label }))
window.setTimeout(() => { void SettingsService.DismissPanel().catch(() => {}) }, 600)
}}
/>
</div>
)
}

if (clipboardApply) {
return (
<div className={styles.panel} data-testid="quick-panel">
Expand Down
Loading
Loading