Skip to content

Commit 47ad8aa

Browse files
alicodingclaude
andauthored
feat: the clipboard bridge -- agent loop without MCP (goal 0099) (#233)
* feat: clipboard bridge Go layer -- protocol core, route node, seeded routes, preview/envelope bindings (goal 0099) domain/clipbridge: the OUT envelope (inline JSON Schema 2020-12, kind/action enums as the only injection points), ParseReply via the vendored jsonschema/v6 with named per-failure errors, and the effect- class taxonomy with the hard cap in code (replace/delete never auto-runs; the subset-guard test pins the schema vocabulary). composition: apply-atlas-from-reply (a thin batch node over an injected materializer) + two seeded routes the Quick Panel runs on accept, both in the seed-proof registry with end-to-end executionsvc proofs. atlassvc: PreviewClipbridgeReply (collision-annotated), CardContextEnvelope (the machine-readable twin of the plain-text context block), and the materializer (kind-label resolution, summary-field mapping, Scratchpad note landing). Contract document regenerated for the new node type; fingerprints extended for the new goldens (all SeedRevision 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd * feat: clipbridge correction envelope -- the re-ask-the-source loop's binding; bindings regenerated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd * feat: clipboard bridge frontend -- one clipboard door, the reply review surface, Copy for AI (goal 0099) The Quick Panel's Apply-from-clipboard row now recognizes both payload families (workflow export first, then the mill reply envelope) via a shared clipboard-door hook, split out to keep QuickPanel under the 500-line cap. A recognized reply swaps the panel into the review surface (the fourth instance of the preview-dialog language): creates render as checkbox rows, colliding titles default unchecked with the collision named, the confirm carries its count, nothing executes before accept, and the run goes through the seeded route workflow. The correction loop is re-ask-the-source (Copy corrected context). The card page's share rail gains Copy for AI, the machine-readable twin of Copy as context. e2e covers the envelope round-trip, the collision-unchecked accept flow, and the invalid-reply correction copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd * fix: reply schema built structurally, never by string formatting (CodeQL: unsafe quoting) The two enum injections now parse the committed literal template and set the arrays as values -- label content is inert data and can never alter the document's shape. Regression test pins a hostile label carrying quotes/refs staying inside its enum string. Both scanner findings on schema.go resolve at the root rather than by suppression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd * test: palette census gains apply-atlas-from-reply (41 node types; advanced by its JSON items field) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 15469e0 commit 47ad8aa

34 files changed

Lines changed: 2305 additions & 321 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
export type {
5+
CardDraft
6+
} from "./models.js";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* CardDraft is one to-be-created card parsed from a valid create-cards
6+
* reply -- exactly what the review surface previews.
7+
*/
8+
export interface CardDraft {
9+
"title": string;
10+
"kind"?: string;
11+
"note"?: string;
12+
"summary"?: string;
13+
}

frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ export function CardContextBlock(cardID: string, withAttachments: boolean): $Can
7171
return $Call.ByID(4150547692, cardID, withAttachments);
7272
}
7373

74+
/**
75+
* CardContextEnvelope renders a card as the OUT envelope (goal 0099):
76+
* its data as items, the reply contract inline. The plain-text
77+
* CardContextBlock stays for human destinations; this is the
78+
* machine-readable twin an external AI answers against.
79+
*/
80+
export function CardContextEnvelope(cardID: string): $CancellablePromise<string> {
81+
return $Call.ByID(1724710751, cardID);
82+
}
83+
7484
/**
7585
* Cards returns every LIVE card (goal 0093: a tombstoned card is
7686
* excluded, and a live child of a tombstoned container carries its
@@ -99,6 +109,15 @@ export function ConvertHTMLToMarkdown(html: string): $CancellablePromise<string>
99109
return $Call.ByID(989919804, html);
100110
}
101111

112+
/**
113+
* CorrectionEnvelope re-emits the reply contract for the
114+
* re-ask-the-source loop: validation problems and declined titles ride
115+
* the instruction line, the schema stays the instruction.
116+
*/
117+
export function CorrectionEnvelope(problems: string[] | null, declinedTitles: string[] | null): $CancellablePromise<string> {
118+
return $Call.ByID(4009289398, problems, declinedTitles);
119+
}
120+
102121
/**
103122
* CreateCard makes a new Card of kindID, optionally inside parentID
104123
* ("" for root-level). A non-empty parentID must name an existing
@@ -425,6 +444,16 @@ export function PickFolder(startDir: string): $CancellablePromise<string> {
425444
return $Call.ByID(3623587391, startDir);
426445
}
427446

447+
/**
448+
* PreviewClipbridgeReply validates a raw clipboard string against the
449+
* reply contract (schema-first, then per-action requirements) and
450+
* annotates it with collision state. Malformed input renders inline --
451+
* this returns a Go error only for internal faults.
452+
*/
453+
export function PreviewClipbridgeReply(raw: string): $CancellablePromise<$models.ClipbridgeReplyPreview> {
454+
return $Call.ByID(3851574839, raw);
455+
}
456+
428457
/**
429458
* PromoteNote is the note's one-way lifecycle event (the LOCKED
430459
* design's "promotion ritual"): it becomes a typed Card in place --

frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export {
99
export type {
1010
AtlasImportSummary,
1111
AtlasSessionState,
12+
ClipbridgeCardOffer,
13+
ClipbridgeReplyPreview,
1214
FileDropCreateResult,
1315
FileDropRoute,
1416
FolderImportSummary,

frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
55
// @ts-ignore: Unused imports
66
import * as atlas$0 from "../../domain/atlas/models.js";
7+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
8+
// @ts-ignore: Unused imports
9+
import * as clipbridge$0 from "../../domain/clipbridge/models.js";
710

811
/**
912
* AtlasImportSummary counts what ImportAtlas did, per family -- the
@@ -47,6 +50,34 @@ export interface AtlasSessionState {
4750
"activePerspectiveID": string;
4851
}
4952

53+
/**
54+
* ClipbridgeCardOffer is one to-be-created card row on the review
55+
* surface: the parsed draft plus its collision flag (the dedupe
56+
* convention -- colliding rows default unchecked).
57+
*/
58+
export interface ClipbridgeCardOffer {
59+
"Draft": clipbridge$0.CardDraft;
60+
"CollidesWithID": string;
61+
"CollidesWithKind": string;
62+
}
63+
64+
/**
65+
* ClipbridgeReplyPreview is what the Quick Panel renders when the
66+
* clipboard carries a mill reply: the domain preview plus the
67+
* Atlas-side collision annotations and the route workflow to run on
68+
* accept.
69+
*/
70+
export interface ClipbridgeReplyPreview {
71+
"Recognized": boolean;
72+
"Valid": boolean;
73+
"Action": string;
74+
"Errors": string[] | null;
75+
"Cards": ClipbridgeCardOffer[] | null;
76+
"NoteTexts": string[] | null;
77+
"RouteWorkflowID": string;
78+
"RouteLabel": string;
79+
}
80+
5081
/**
5182
* FileDropCreateResult is CreateCardFromFileDrop's own response --
5283
* wraps the newly created card with an additive duplicate-detection
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { test, expect } from './fixtures/server'
2+
import { withClipboardLock } from './fixtures/clipboardLock'
3+
import { noteCard, openCard } from './fixtures/atlasBoard'
4+
import { deleteViaPageMenu } from './fixtures/atlasPage'
5+
6+
// The clipboard bridge (goal 0099): Copy for AI emits the JSON
7+
// envelope; the Quick Panel's clipboard door recognizes a reply,
8+
// renders the review surface (collisions default-unchecked), and the
9+
// accept runs the seeded route workflow. Shared worker pool: every
10+
// entity this file creates it deletes; clipboard sections take the
11+
// cross-process lock per testing.md's real-pasteboard discipline.
12+
13+
async function openPanelWithClipboard(page: import('@playwright/test').Page, payload: string) {
14+
await page.goto('about:blank')
15+
await page.goto('/#/quickpanel')
16+
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
17+
await page.evaluate((t) => navigator.clipboard.writeText(t), payload)
18+
const search = page.getByRole('combobox', { name: 'Quick Panel search' })
19+
await expect(search).toBeFocused()
20+
await search.fill('apply from clipboard')
21+
const option = page.getByRole('option', { name: 'Apply from clipboard…' })
22+
await expect(option).toBeVisible()
23+
await option.click()
24+
}
25+
26+
test('Copy for AI puts the reply-contract envelope on the clipboard', async ({ page }) => {
27+
await withClipboardLock(async () => {
28+
await page.goto('/')
29+
await page.getByRole('link', { name: 'Atlas' }).click()
30+
await expect(page.getByTestId('atlas-board')).toBeVisible()
31+
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
32+
33+
const card = noteCard(page, 'Getting started')
34+
await openCard(page, card)
35+
await page.getByTestId('atlas-overlay-copy-for-ai').click()
36+
37+
// The copy handler's binding round-trip + clipboard write are
38+
// async relative to the click -- poll until the envelope lands.
39+
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mill"')
40+
const raw = await page.evaluate(() => navigator.clipboard.readText())
41+
const envelope = JSON.parse(raw)
42+
expect(envelope.mill).toBe(1)
43+
expect(envelope.kind).toBe('context')
44+
expect(envelope.schema.type).toBe('object')
45+
expect(envelope.allowedActions).toContain('create-cards')
46+
expect(envelope.items[0].title).toBe('Getting started')
47+
expect(envelope.instructions).toContain('JSON code block')
48+
await page.keyboard.press('Escape')
49+
})
50+
})
51+
52+
test('a valid reply reviews with collisions unchecked, and accepting creates only the checked card', async ({ page }) => {
53+
const freshTitle = 'ZzE2eBridgeCard'
54+
await withClipboardLock(async () => {
55+
const reply = JSON.stringify({
56+
mill: 1, kind: 'reply', action: 'create-cards',
57+
items: [{ title: 'Getting started' }, { title: freshTitle, note: 'from the reply' }],
58+
})
59+
await openPanelWithClipboard(page, reply)
60+
61+
const review = page.getByTestId('quick-panel-reply-review')
62+
await expect(review).toBeVisible()
63+
const checkboxes = review.getByTestId('quick-panel-reply-card-checkbox')
64+
await expect(checkboxes).toHaveCount(2)
65+
await expect(checkboxes.nth(0)).not.toBeChecked()
66+
await expect(checkboxes.nth(1)).toBeChecked()
67+
await expect(review.getByText(/Already exists as/)).toBeVisible()
68+
69+
const confirm = review.getByTestId('quick-panel-reply-confirm')
70+
await expect(confirm).toContainText('Create 1 card')
71+
await confirm.click()
72+
await expect(review).toHaveCount(0)
73+
})
74+
75+
// The accepted card exists; the declined collision stayed singular.
76+
await page.goto('/')
77+
await page.getByRole('link', { name: 'Atlas' }).click()
78+
await expect(page.getByTestId('atlas-board')).toBeVisible()
79+
const created = noteCard(page, freshTitle)
80+
await expect(created).toBeVisible()
81+
await expect(noteCard(page, 'Getting started')).toHaveCount(1)
82+
83+
// Cleanup (within-file discipline).
84+
await openCard(page, created)
85+
const overlay = page.locator('[data-component="atlas-card-overlay"]')
86+
await deleteViaPageMenu(page, overlay)
87+
await expect(created).toHaveCount(0)
88+
})
89+
90+
test('an invalid reply names its failures and Copy corrected context re-emits the contract', async ({ page }) => {
91+
await withClipboardLock(async () => {
92+
const bad = JSON.stringify({ mill: 1, kind: 'reply', action: 'create-cards', items: [{ note: 'no title here' }] })
93+
await openPanelWithClipboard(page, bad)
94+
95+
const invalid = page.getByTestId('quick-panel-reply-invalid')
96+
await expect(invalid).toBeVisible()
97+
await expect(invalid).toContainText('title')
98+
99+
await invalid.getByTestId('quick-panel-reply-copy-correction').click()
100+
await expect(invalid.getByTestId('quick-panel-reply-copy-correction')).toContainText('Copied')
101+
const raw = await page.evaluate(() => navigator.clipboard.readText())
102+
const envelope = JSON.parse(raw)
103+
expect(envelope.kind).toBe('context')
104+
expect(envelope.instructions).toContain('did not validate')
105+
expect(envelope.schema.properties.action.enum).toContain('create-cards')
106+
})
107+
})

frontend/e2e/composition.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ test('Composition page lists built-in workflows; node primitives live in a colla
9292
// integration, goal 0066, ADR-0035/0038) + apply-backup-snapshot
9393
// (goal 0065's data-stewardship backup step) + apply-list-row (the
9494
// Lists write path, goal 0070) + apply-file-move (file verbs, goal
95-
// 0087).
96-
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(40)
95+
// 0087) + apply-atlas-from-reply (the clipboard bridge's accepted-
96+
// reply materializer, goal 0099).
97+
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(41)
9798
})
9899

99100
test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => {

frontend/e2e/node-palette.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test('palette search matches both the shortened display name and the full underl
9797
// RegisterNodeType call sites + the seeded "Check httpbin" declared
9898
// step type, goal 0054 slice A).
9999
await search.fill('')
100-
await expect(panel.getByTestId('palette-item')).toHaveCount(40)
100+
await expect(panel.getByTestId('palette-item')).toHaveCount(41)
101101
})
102102

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

114114
test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, and persists across a reload', async ({ page }) => {
@@ -130,6 +130,8 @@ test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, a
130130
'process-atlas-card-find', 'apply-atlas-card-create', 'apply-atlas-card-update',
131131
// goal 0070: fieldBindings is the same hand-authored JSON shape.
132132
'apply-list-row',
133+
// goal 0099: consumes a JSON items array from an attribute.
134+
'apply-atlas-from-reply',
133135
]
134136
for (const id of advancedIDs) {
135137
await expect(panel.locator(`[data-node-type-id="${id}"]`)).toHaveCount(0)

frontend/src/app/QuickPanel.tsx

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { Events } from '@wailsio/runtime'
44
import { Text } from '@primer/react'
55
import { FilteredActionList } from '@primer/react/experimental'
66
import { NoteIcon, PlayIcon } from '@primer/octicons-react'
7-
import { AtlasService, CompositionService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings'
8-
import type { ClipboardApplyPreview } from '../shared/bindings'
7+
import { AtlasService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings'
98
import { generateSamplePayload } from '../shared/configSchema'
109
import { useAppStore, refreshWorkflows, refreshRequests, refreshKeybindings } from '../shared/store'
1110
import {
@@ -19,6 +18,8 @@ import { buildConfigureAndActionEntries } from './quickPanelActionEntries'
1918
import type { PanelEntry } from './quickPanelActionEntries'
2019
import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture'
2120
import { QuickPanelClipboardApply } from './QuickPanelClipboardApply'
21+
import { QuickPanelReplyReview } from './QuickPanelReplyReview'
22+
import { useQuickPanelClipboardDoor } from './useQuickPanelClipboardDoor'
2223
import { FacetChipRow } from '../shared/FacetChipRow'
2324
import { useQuickPanelFacetSearch } from './quickPanelFacets'
2425
import styles from './QuickPanel.module.css'
@@ -125,14 +126,6 @@ export function QuickPanel() {
125126
// those; duplicating them per-window would double-fire OS
126127
// notifications for the same pending item).
127128
const [reviewPendingCount, setReviewPendingCount] = useState(0)
128-
// docs/goals/0039: non-null swaps the panel body from the search list
129-
// into QuickPanelClipboardApply's preview-confirm view. json is the
130-
// exact clipboard text the preview was computed from -- re-sent to
131-
// ConfirmClipboardApply on confirm rather than re-read from the
132-
// clipboard a second time (the user's gesture already captured it
133-
// once; a second OS-level read has no reason to differ and would
134-
// just be a second permission prompt).
135-
const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null)
136129
const inputRef = useRef<HTMLInputElement>(null)
137130

138131
// Declared before the effects that reference them (react-hooks/
@@ -318,32 +311,9 @@ export function QuickPanel() {
318311
})
319312
}
320313

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

348318
// The away-capture door (docs/goals/0090): a typed query with no
349319
// intent to search becomes a Note instead, filed into the Scratchpad
@@ -452,6 +422,22 @@ export function QuickPanel() {
452422
// (ADR-0033) has no room for a second, nested surface, so this is a
453423
// full replacement, not an overlay. Cancel/Applied both clear the
454424
// state, returning to the ordinary search list.
425+
if (replyReview) {
426+
return (
427+
<div className={styles.panel} data-testid="quick-panel">
428+
<QuickPanelReplyReview
429+
preview={replyReview}
430+
onCancel={() => setReplyReview(null)}
431+
onApplied={(label) => {
432+
setReplyReview(null)
433+
setStatus(t('quickPanel.status.replyApplied', { label }))
434+
window.setTimeout(() => { void SettingsService.DismissPanel().catch(() => {}) }, 600)
435+
}}
436+
/>
437+
</div>
438+
)
439+
}
440+
455441
if (clipboardApply) {
456442
return (
457443
<div className={styles.panel} data-testid="quick-panel">

0 commit comments

Comments
 (0)