Skip to content

Commit fbbb2ed

Browse files
alicodingclaude
andauthored
feat: clipboard history as guarded capture (goal 0234 S1) (#472)
* feat: clipboard history as guarded capture (goal 0234 S1) Arrives as composition (ADR-0035), never a Settings toggle: a new trigger-clipboard-change NodeType polls the real clipboard at 1s, screening out confidential-marked content (org.nspasteboard. ConcealedType/TransientType/AutoGeneratedType) and Mill's own programmatic writes before it ever fires, and feeds a new apply-clipboard-history-store NodeType that redacts through the existing SetSecretRedactor seam before persisting. Composed into one seeded "Clipboard history" workflow, published but disabled by default -- turning it on is the consent moment for reading the clipboard at all. Every copy-back leaves an audit line through the existing secret-read audit store (0203 S3), reused rather than duplicated. Retention: 200 unpinned entries, oldest evicted, pinned entries exempt (text only in S1). The Clipboard history dialog (clipboard.history.open command) is Raycast-shaped: a searchable list with pins floating to the top, selecting a row previews its full multi-line content in monospace only when it looks like code. Also fixes a discovered pre-existing cross-package race: the real macOS clipboard is one shared OS resource, and internal/adapters/ clipboard's own real-desktop tests could collide with triggersvc's saved-page seed test under `go test ./...`'s default parallelism -- a new clipboardtest.WithRealClipboardLock (mirroring the e2e suite's own clipboardLock.ts) now serializes both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * test: update palette-item counts for goal 0234's 2 new NodeTypes (44->46) trigger-clipboard-change and apply-clipboard-history-store bring the palette census from 43 RegisterNodeType call sites to 45 (+1 declared step type = 46 total), which composition.spec.ts and node-palette.spec.ts both hardcode. Verified against the real registry (grep RegisterNodeType call sites = 45) and against a local run of both spec files, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 735a0e1 commit fbbb2ed

53 files changed

Lines changed: 2148 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ export interface BoardObject {
7171
* contract, extended to this type) -- zero value means live.
7272
*/
7373
"DeletedAt": string;
74+
75+
/**
76+
* BuiltIn and Seed carry the exact same seed-provenance contract
77+
* Card already does (goal 0037/0223): BuiltIn marks a golden
78+
* atlassvc's reconcile inserted, Seed tracks which revision and
79+
* whether the user has since touched it. A zero Seed means
80+
* user-created, same as a Card with no seed origin.
81+
*/
82+
"BuiltIn": boolean;
83+
"Seed": seedorigin$0.Origin;
7484
}
7585

7686
/**
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+
Entry
6+
} from "./models.js";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* Entry is one captured clipboard text, already redacted (known secret
6+
* values and secret-shaped patterns scrubbed) and already screened
7+
* (never a confidential-marked or Mill-self-written value) by the time
8+
* it reaches this package -- see the apply-clipboard-history-store
9+
* node's own doc comment for where those checks happen.
10+
*/
11+
export interface Entry {
12+
"ID": string;
13+
"Text": string;
14+
"CreatedAt": string;
15+
"Pinned": boolean;
16+
}

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,10 @@ export function CreatePerspective(spaceID: string, name: string, description: st
264264

265265
/**
266266
* DeleteBoardObject soft-deletes a board object (goal 0179/0180) --
267-
* same tombstone contract as DeleteNote: no seed-tombstone bookkeeping
268-
* (a board object carries no seed provenance), no link/child blast
269-
* radius (a board object can be neither a link endpoint nor a
270-
* container).
267+
* same tombstone contract as DeleteCard's own seed-tombstone bookkeeping
268+
* (goal 0223 gives BoardObject the same BuiltIn/Seed provenance Card
269+
* already carries), minus DeleteCard's link/child blast radius (a
270+
* board object can be neither a link endpoint nor a container).
271271
*/
272272
export function DeleteBoardObject(id: string): $CancellablePromise<$models.TombstoneResult> {
273273
return $Call.ByID(309976208, id);
@@ -1075,9 +1075,9 @@ export function Undo(): $CancellablePromise<$models.UndoResult> {
10751075
* UndoDelete reverses one or more DeleteCard/DeleteNote/
10761076
* DeleteBoardObject calls: clears DeletedAt on exactly the ids named (a
10771077
* no-op for any id that's no longer tombstoned, e.g. already purged)
1078-
* and clears a built-in card's seed tombstone too, so top-up seeding
1079-
* can reach it again. cardIDs/noteIDs/objectIDs are the exact
1080-
* TombstoneResult(s) the original delete call(s) returned.
1078+
* and clears a built-in card's or board object's seed tombstone too, so
1079+
* top-up seeding can reach it again. cardIDs/noteIDs/objectIDs are the
1080+
* exact TombstoneResult(s) the original delete call(s) returned.
10811081
*/
10821082
export function UndoDelete(cardIDs: string[] | null, noteIDs: string[] | null, objectIDs: string[] | null): $CancellablePromise<void> {
10831083
return $Call.ByID(4101274755, cardIDs, noteIDs, objectIDs);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* ClipboardHistoryService holds every captured entry in memory,
6+
* persisted as one JSON blob -- the same settings-store-backed shape
7+
* guardrailsvc.GuardrailService already uses for a small, infrequently
8+
* large collection (at most MaxUnpinned + however many are pinned).
9+
* @module
10+
*/
11+
12+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
13+
// @ts-ignore: Unused imports
14+
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
15+
16+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
17+
// @ts-ignore: Unused imports
18+
import * as clipboardhistory$0 from "../../domain/clipboardhistory/models.js";
19+
20+
/**
21+
* CopyClipboardHistoryEntry writes id's text back to the real
22+
* clipboard and records one audit line (goal 0234, reusing 0203 S3's
23+
* audit plane). clipboard.WriteText also marks this write as Mill's
24+
* own, so the trigger-clipboard-change poller skips re-capturing it as
25+
* a new entry on the very next cycle.
26+
*/
27+
export function CopyClipboardHistoryEntry(id: string): $CancellablePromise<void> {
28+
return $Call.ByID(2401224501, id);
29+
}
30+
31+
/**
32+
* DeleteClipboardHistoryEntry permanently removes id -- no undo, same
33+
* posture SecretService.DeleteSecret already takes for its own entries.
34+
*/
35+
export function DeleteClipboardHistoryEntry(id: string): $CancellablePromise<void> {
36+
return $Call.ByID(934528427, id);
37+
}
38+
39+
/**
40+
* ListClipboardHistory returns every entry, pinned-first then newest-
41+
* first (clipboardhistory.SortForDisplay) -- the order the Clipboard
42+
* history dialog renders directly.
43+
*/
44+
export function ListClipboardHistory(): $CancellablePromise<clipboardhistory$0.Entry[] | null> {
45+
return $Call.ByID(2660370174);
46+
}
47+
48+
/**
49+
* SetClipboardHistoryPinned pins or unpins id -- a pinned entry floats
50+
* to the top of ListClipboardHistory and is never evicted by Append's
51+
* retention cap, regardless of age.
52+
*/
53+
export function SetClipboardHistoryPinned(id: string, pinned: boolean): $CancellablePromise<void> {
54+
return $Call.ByID(3165372480, id, pinned);
55+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
import * as ClipboardHistoryService from "./clipboardhistoryservice.js";
5+
export {
6+
ClipboardHistoryService
7+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { chromium, expect, test } from '@playwright/test'
2+
import { mkdtempSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { CLIPBOARD_HISTORY_MCP_BASE_PORT, CLIPBOARD_HISTORY_SERVER_BASE_PORT, spawnMillServer, type SpawnedServer } from './fixtures/server'
6+
import { paletteDialog } from './fixtures/palette'
7+
import { seedClipboardHistoryEntry } from './fixtures/clipboardHistorySeed'
8+
import { withClipboardLock } from './fixtures/clipboardLock'
9+
10+
// Clipboard history (goal 0234): the surface's own task -- search,
11+
// preview, copy, pin, and delete what's been captured -- driven end to
12+
// end. Dedicated server (own settings file): the entry list is GLOBAL
13+
// app state (testing.md's shared-vs-dedicated rule) and this spec
14+
// asserts the true empty state, which needs a guaranteed-fresh
15+
// settings file. Entries are seeded via ExecutionService.
16+
// RunWorkflowWithPayload (fixtures/clipboardHistorySeed.ts), never a
17+
// real clipboard copy -- goal 0234's own e2e-divergence note: the real
18+
// trigger needs an actual macOS clipboard change to fire, which is
19+
// CI-hostile on headless runners.
20+
// eslint-disable-next-line no-empty-pattern -- needs `testInfo`, not any fixture.
21+
test('Clipboard history: empty state, then search/preview/copy/pin/delete a real seeded entry', async ({}, testInfo) => {
22+
const idx = testInfo.parallelIndex
23+
const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-clipboard-history-${idx}-`))
24+
const settingsPath = path.join(dir, 'settings.json')
25+
const executionDbPath = path.join(dir, 'execution.db')
26+
const backupDir = path.join(dir, 'backups')
27+
const port = CLIPBOARD_HISTORY_SERVER_BASE_PORT + idx
28+
const mcpPort = CLIPBOARD_HISTORY_MCP_BASE_PORT + idx
29+
30+
let server: SpawnedServer | undefined
31+
const browser = await chromium.launch()
32+
try {
33+
server = await spawnMillServer({ port, mcpPort, settingsPath, executionDbPath, backupDir })
34+
const page = await browser.newPage()
35+
await page.goto(`${server.baseURL}/`)
36+
await expect(page.getByRole('link', { name: 'Home' })).toBeVisible()
37+
38+
const openClipboardHistory = async () => {
39+
await page.keyboard.press('Meta+K')
40+
await expect(paletteDialog(page)).toBeVisible()
41+
await paletteDialog(page).getByRole('combobox').fill('clipboard history')
42+
await paletteDialog(page).getByRole('option', { name: 'Clipboard history', exact: true }).click()
43+
}
44+
45+
const dialog = page.getByRole('dialog', { name: 'Clipboard history' })
46+
47+
// --- Empty state offers the door to the workflow that fills it ---
48+
await openClipboardHistory()
49+
await expect(dialog).toBeVisible()
50+
await expect(page.getByTestId('clipboard-history-empty')).toContainText('Copies appear here once the Clipboard history workflow is on.')
51+
const openWorkflowButton = page.getByTestId('clipboard-history-open-workflow')
52+
await expect(openWorkflowButton).toBeVisible()
53+
await openWorkflowButton.click()
54+
// Opening the door closes the dialog and lands on the seeded
55+
// workflow's own editor tab.
56+
await expect(dialog).toBeHidden()
57+
await expect(page.getByRole('tab', { name: 'Clipboard history', selected: true })).toBeVisible()
58+
59+
// --- Seed two real entries via the same production run path a
60+
// real capture goes through, one plain and one code-shaped ---
61+
await seedClipboardHistoryEntry(page, 'buy milk on the way home')
62+
await seedClipboardHistoryEntry(page, 'function greet() {\n console.log("hi")\n}')
63+
64+
await openClipboardHistory()
65+
await expect(dialog).toBeVisible()
66+
67+
const codeRow = dialog.getByRole('option', { name: /function greet/ })
68+
const plainRow = dialog.getByRole('option', { name: /buy milk/ })
69+
await expect(codeRow).toBeVisible()
70+
await expect(plainRow).toBeVisible()
71+
72+
// --- Search filters the list ---
73+
const search = dialog.getByPlaceholder('Search clipboard history')
74+
await search.fill('milk')
75+
await expect(plainRow).toBeVisible()
76+
await expect(codeRow).toBeHidden()
77+
await search.fill('')
78+
79+
// --- Selecting the code entry previews it in monospace, full
80+
// multi-line content preserved ---
81+
await codeRow.click()
82+
const detailText = page.getByTestId('clipboard-history-detail-text')
83+
await expect(detailText).toContainText('function greet() {')
84+
await expect(detailText).toContainText('console.log("hi")')
85+
await expect(detailText).toHaveCSS('font-family', /mono/i)
86+
87+
// --- Selecting the plain entry previews it WITHOUT monospace ---
88+
await plainRow.click()
89+
await expect(detailText).toHaveText('buy milk on the way home')
90+
await expect(detailText).not.toHaveCSS('font-family', /mono/i)
91+
92+
// --- Copy touches the real OS pasteboard (clipboard.WriteText),
93+
// one shared resource across every worker (fixtures/clipboardLock.ts)
94+
// -- outcome asserted success-or-error, not pinned to success:
95+
// pbcopy doesn't exist on a headless Linux CI runner, same
96+
// environment-independent pattern secrets.spec.ts's own copy
97+
// assertion already uses. ---
98+
await withClipboardLock(async () => {
99+
await page.getByTestId('clipboard-history-copy').click()
100+
const copiedState = page.getByTestId('clipboard-history-copy').getByText('Copied', { exact: true })
101+
const errorState = page.getByTestId('clipboard-history-copy-error')
102+
await expect(copiedState.or(errorState)).toBeVisible()
103+
})
104+
105+
// --- Pin floats the plain entry above the code entry (it's still
106+
// selected from the Copy step above) ---
107+
const pinButton = page.getByTestId('clipboard-history-pin')
108+
await expect(pinButton).toHaveText('Pin')
109+
await pinButton.click()
110+
await expect(pinButton).toHaveText('Unpin')
111+
await expect(dialog.getByRole('option').first()).toHaveAccessibleName(/buy milk/)
112+
113+
// --- Delete removes the selected entry; deleting the last one
114+
// returns the dialog to its empty state ---
115+
await codeRow.click()
116+
await page.getByTestId('clipboard-history-delete').click()
117+
await expect(codeRow).toBeHidden()
118+
await expect(plainRow).toBeVisible()
119+
120+
await plainRow.click()
121+
await page.getByTestId('clipboard-history-delete').click()
122+
await expect(page.getByTestId('clipboard-history-empty')).toBeVisible()
123+
} finally {
124+
await browser.close()
125+
await server?.stop()
126+
rmSync(dir, { recursive: true, force: true })
127+
}
128+
})

frontend/e2e/composition.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,10 @@ test('Composition page lists built-in workflows; node primitives live in a colla
9494
// Lists write path, goal 0070) + apply-file-move (file verbs, goal
9595
// 0087) + apply-atlas-from-reply (the clipboard bridge's accepted-
9696
// reply materializer, goal 0099) + apply-atlas-ledger-sync (the
97-
// delivery-evidence ledger's own mirror node, goal 0164).
98-
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(44)
97+
// delivery-evidence ledger's own mirror node, goal 0164) +
98+
// trigger-clipboard-change, apply-clipboard-history-store (goal
99+
// 0234's guarded clipboard-history capture).
100+
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(46)
99101
})
100102

101103
test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Page } from '@playwright/test'
2+
3+
// Seeds a real Clipboard history entry by running the seeded
4+
// "Clipboard history" workflow's own graph directly via
5+
// ExecutionService.RunWorkflowWithPayload -- the SAME bound RPC the
6+
// canvas's own test-run-with-payload feature already uses (docs/adr/
7+
// 0008), called by its stable Go method name rather than driven
8+
// through a real clipboard change. Goal 0234's own e2e-divergence note:
9+
// the real trigger needs an actual macOS clipboard change to fire,
10+
// which is CI-hostile (GitHub's headless runners have no pasteboard
11+
// session); this exercises the exact same production
12+
// apply-clipboard-history-store step every real capture goes through,
13+
// just started a different way -- same reasoning as
14+
// atlasNativeDropEscapeHatch.ts's own CreateBoardObject call. Runs the
15+
// workflow's DRAFT head (RunKind "test"), which the seed ships
16+
// PUBLISHED-and-identical to, so this works whether or not the seed
17+
// has been enabled.
18+
export async function seedClipboardHistoryEntry(page: Page, text: string): Promise<void> {
19+
const result = await page.evaluate(async (text) => {
20+
const callID = `${Date.now()}-${Math.random().toString(36).slice(2)}`
21+
const body = {
22+
object: 0, // @wailsio/runtime's objectNames.Call
23+
method: 0, // CallBinding
24+
args: {
25+
'call-id': callID,
26+
methodName: 'github.com/alicoding/mill/internal/services/executionsvc.ExecutionService.RunWorkflowWithPayload',
27+
args: ['clipboard-history-workflow', 'test', null, text],
28+
},
29+
}
30+
const res = await fetch(window.location.origin + '/wails/runtime', {
31+
method: 'POST',
32+
headers: { 'x-wails-client-id': 'e2e-clipboard-history-seed', 'Content-Type': 'application/json' },
33+
body: JSON.stringify(body),
34+
})
35+
return { ok: res.ok, status: res.status, text: await res.text() }
36+
}, text)
37+
if (!result.ok) {
38+
throw new Error(`seedClipboardHistoryEntry(${JSON.stringify(text)}) failed: ${result.status} ${result.text}`)
39+
}
40+
}

frontend/e2e/fixtures/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,11 @@ export const UPDATES_WHATSNEW_EMPTY_MCP_BASE_PORT = 10980
490490
// rendered notes.
491491
export const UPDATES_WHATSNEW_NOTES_SERVER_BASE_PORT = 11000
492492
export const UPDATES_WHATSNEW_NOTES_MCP_BASE_PORT = 11020
493+
494+
// clipboard-history.spec.ts (goal 0234): dedicated, same reasoning as
495+
// SECRETS_SERVER_BASE_PORT above -- Clipboard history's own entry list
496+
// is GLOBAL app state (testing.md's shared-vs-dedicated rule), and this
497+
// spec also asserts the true EMPTY state, which needs a guaranteed-
498+
// fresh settings file no other spec could have already written into.
499+
export const CLIPBOARD_HISTORY_SERVER_BASE_PORT = 11040
500+
export const CLIPBOARD_HISTORY_MCP_BASE_PORT = 11060

0 commit comments

Comments
 (0)