Skip to content

Commit dd257c9

Browse files
authored
Merge pull request #8 from alicoding/goal/0023-attention-escalation
Attention escalation: idle-aware presence, floating approval prompt, cross-device forward (goal 0023)
2 parents 98153f4 + d4dec2a commit dd257c9

23 files changed

Lines changed: 1315 additions & 56 deletions

frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,23 @@ import * as guardrail$0 from "../../domain/guardrail/models.js";
2424
import * as $models from "./models.js";
2525

2626
/**
27-
* CreateRule validates and stores a new rule, minting its ID.
27+
* CreateRule validates and stores a new rule, minting its ID. On a
28+
* persist failure the appended rule is rolled back rather than left
29+
* live in memory only -- a rule that silently failed to save must not
30+
* appear to be gating anything (docs/goals/0025 item 2's memory-vs-
31+
* store consistency rule, applied to guardrail rules too since a
32+
* phantom-saved rule here is worse than most: it's the thing deciding
33+
* whether a step needs approval).
2834
*/
2935
export function CreateRule(rule: guardrail$0.Rule): $CancellablePromise<guardrail$0.Rule> {
3036
return $Call.ByID(3694735496, rule);
3137
}
3238

3339
/**
3440
* DeleteRule removes a rule by ID; deleting an absent rule is a no-op,
35-
* matching every other Configure entity's delete semantics.
41+
* matching every other Configure entity's delete semantics. Returns the
42+
* persist error (rather than swallowing it, docs/goals/0025 item 1) and
43+
* restores the deleted rule if the store write fails.
3644
*/
3745
export function DeleteRule(id: string): $CancellablePromise<void> {
3846
return $Call.ByID(1475597571, id);
@@ -58,7 +66,7 @@ export function TestRules(workflowID: string, nodeID: string): $CancellablePromi
5866

5967
/**
6068
* UpdateRule replaces an existing rule in place, same validation as
61-
* create.
69+
* create; rolls back to the previous rule value if the persist fails.
6270
*/
6371
export function UpdateRule(rule: guardrail$0.Rule): $CancellablePromise<void> {
6472
return $Call.ByID(2711428157, rule);

frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,27 @@ export function CheckForUpdates(): $CancellablePromise<$models.UpdateCheckResult
5454

5555
/**
5656
* ClearKeybinding removes commandID's override, if any, reverting it to
57-
* its frontend-declared default binding.
57+
* its frontend-declared default binding. Returns the persist error
58+
* (docs/goals/0025 item 1) rather than swallowing it, and restores the
59+
* removed override in memory if the save failed, so a user who thinks
60+
* they cleared a binding doesn't have it silently come back after a
61+
* restart while the UI shows it as already cleared.
5862
*/
5963
export function ClearKeybinding(commandID: string): $CancellablePromise<void> {
6064
return $Call.ByID(3036020909, commandID);
6165
}
6266

67+
/**
68+
* DismissApprovalPrompt hides the floating approval prompt and applies
69+
* the same focus-yield mitigation DismissPanel already uses -- called
70+
* by the prompt's own frontend once the last pending item resolves (or
71+
* there's nothing left to show on mount), the DismissPanel-equivalent
72+
* RPC for this window.
73+
*/
74+
export function DismissApprovalPrompt(): $CancellablePromise<void> {
75+
return $Call.ByID(2270399157);
76+
}
77+
6378
/**
6479
* DismissPanel hides the Quick Panel and applies the focus-yield
6580
* mitigation -- the bound RPC the panel's own frontend calls after a
@@ -71,6 +86,30 @@ export function DismissPanel(): $CancellablePromise<void> {
7186
return $Call.ByID(3877156882);
7287
}
7388

89+
/**
90+
* ForwardPendingApproval fires the configured cross-device forward for
91+
* one new pending item -- fire-and-forget: it returns immediately, and
92+
* a delivery failure is only slog-logged, never surfaced back to the
93+
* caller or allowed to block anything. App.tsx's own per-new-item loop
94+
* calls this alongside NotifyPendingApproval, unconditionally --
95+
* unlike the presence gate, forwarding doesn't depend on local
96+
* focus/idle at all, since the whole point is reaching the owner when
97+
* there may be no local Mac attention to gate on in the first place.
98+
*/
99+
export function ForwardPendingApproval(id: string, description: string, kind: string): $CancellablePromise<void> {
100+
return $Call.ByID(4270514639, id, description, kind);
101+
}
102+
103+
/**
104+
* GetAttentionIdleThreshold returns the configured idle-seconds
105+
* threshold: the presence gate below treats the user as away once
106+
* idletime.Seconds() reaches this, even while the window is focused --
107+
* the fix for the focused-but-idle suppression case.
108+
*/
109+
export function GetAttentionIdleThreshold(): $CancellablePromise<number> {
110+
return $Call.ByID(420254759);
111+
}
112+
74113
/**
75114
* GetBuildInfo reports which commit this running instance was actually
76115
* built from (settingsservice_buildinfo.go) -- surfaced in the footer
@@ -82,6 +121,21 @@ export function GetBuildInfo(): $CancellablePromise<$models.BuildInfo> {
82121
return $Call.ByID(2673585232);
83122
}
84123

124+
/**
125+
* GetForwardApprovalsEnabled reports whether the forward is armed.
126+
*/
127+
export function GetForwardApprovalsEnabled(): $CancellablePromise<boolean> {
128+
return $Call.ByID(3422319504);
129+
}
130+
131+
/**
132+
* GetForwardApprovalsRequestID returns the configured HTTPRequest's ID
133+
* (empty means unconfigured).
134+
*/
135+
export function GetForwardApprovalsRequestID(): $CancellablePromise<string> {
136+
return $Call.ByID(1205123449);
137+
}
138+
85139
/**
86140
* GetLaunchAtLogin queries the real OS state (System Events' login
87141
* items list) rather than a persisted preference -- authoritative even
@@ -169,16 +223,23 @@ export function ListWorkflowMinutesSaved(): $CancellablePromise<{ [_ in string]?
169223
}
170224

171225
/**
172-
* NotifyPendingApproval sends an actionable OS notification for a new
173-
* pending item (docs/adr/0032 §3). kind "mcp-write" gets Approve/Deny
174-
* action buttons resolving directly via ResolveMCPWrite; any other kind
175-
* (a guardrail/human-review park) gets a plain notification whose
176-
* default click shows+focuses the window instead -- typed input may be
177-
* required to resolve those, so blind approval from a notification
178-
* isn't offered.
226+
* NotifyPendingApproval sends an actionable OS notification AND shows
227+
* the floating approval prompt (docs/goals/0023 item 1) for a new
228+
* pending item (docs/adr/0032 §3), but ONLY when isAway(focused) says
229+
* the user is away -- the single decision point both surfaces share, so
230+
* "notify" and "show the floating prompt" can never disagree about
231+
* presence. focused is the caller's own document.hasFocus() reading
232+
* (App.tsx) -- only the browser context knows that; everything else
233+
* about presence (idle time, the threshold) is resolved in isAway.
234+
*
235+
* kind "mcp-write" gets Approve/Deny action buttons resolving directly
236+
* via ResolveMCPWrite; any other kind (a guardrail/human-review park)
237+
* gets a plain notification whose default click shows+focuses the main
238+
* window instead -- typed input may be required to resolve those, so
239+
* blind approval from a notification isn't offered.
179240
*/
180-
export function NotifyPendingApproval(id: string, description: string, kind: string): $CancellablePromise<void> {
181-
return $Call.ByID(99139683, id, description, kind);
241+
export function NotifyPendingApproval(id: string, description: string, kind: string, focused: boolean): $CancellablePromise<void> {
242+
return $Call.ByID(99139683, id, description, kind, focused);
182243
}
183244

184245
/**
@@ -243,6 +304,36 @@ export function RestoreSummonHotkey(): $CancellablePromise<void> {
243304
return $Call.ByID(442997791);
244305
}
245306

307+
/**
308+
* SetAttentionIdleThreshold persists seconds (a non-positive value
309+
* resets to the default, mirroring a cleared Settings field rather than
310+
* persisting a nonsensical threshold), returning the persist error --
311+
* same reasoning as every other settings toggle in this package: a
312+
* save that silently didn't take effect leaves the user believing a
313+
* threshold applies when it doesn't.
314+
*/
315+
export function SetAttentionIdleThreshold(seconds: number): $CancellablePromise<void> {
316+
return $Call.ByID(454955395, seconds);
317+
}
318+
319+
/**
320+
* SetForwardApprovalsEnabled persists the toggle, returning the persist
321+
* error (same reasoning as every other settings toggle here: a save
322+
* that silently didn't take effect leaves the user believing the
323+
* forward is armed when it isn't, or vice versa).
324+
*/
325+
export function SetForwardApprovalsEnabled(enabled: boolean): $CancellablePromise<void> {
326+
return $Call.ByID(1952175932, enabled);
327+
}
328+
329+
/**
330+
* SetForwardApprovalsRequestID persists which Configure-authored
331+
* HTTPRequest to forward pending-approval events through.
332+
*/
333+
export function SetForwardApprovalsRequestID(id: string): $CancellablePromise<void> {
334+
return $Call.ByID(60838469, id);
335+
}
336+
246337
/**
247338
* SetKeybinding overrides commandID's binding to mods+key, rejecting a
248339
* combo already claimed by another command's override, or by a
@@ -265,10 +356,24 @@ export function SetLaunchAtLogin(enabled: boolean): $CancellablePromise<void> {
265356
return $Call.ByID(2080722787, enabled);
266357
}
267358

359+
/**
360+
* SetMCPWriteApprovalRequired returns the persist error, same reasoning
361+
* as SetMCPWriteEnabled -- relaxing this to "unattended" (required =
362+
* false) failing to save silently is a security-relevant gap;
363+
* re-tightening it (required = true) failing to save silently would be
364+
* worse (the user believes approval is required again when it isn't).
365+
*/
268366
export function SetMCPWriteApprovalRequired(required: boolean): $CancellablePromise<void> {
269367
return $Call.ByID(4059084081, required);
270368
}
271369

370+
/**
371+
* SetMCPWriteEnabled returns the persist error (docs/goals/0025 item 1)
372+
* rather than swallowing it -- this is a security-relevant toggle
373+
* (whether an external MCP client may write to this instance at all);
374+
* a click that silently failed to take effect is exactly the kind of
375+
* gap §8's fail-safe posture exists to prevent.
376+
*/
272377
export function SetMCPWriteEnabled(enabled: boolean): $CancellablePromise<void> {
273378
return $Call.ByID(213255560, enabled);
274379
}
@@ -361,6 +466,9 @@ export function SuspendMenuAccelerators(): $CancellablePromise<void> {
361466

362467
/**
363468
* UnassignSummonHotkey removes the app-level summon hotkey, if any.
469+
* Returns the persist error (docs/goals/0025 item 1) rather than
470+
* swallowing it, restoring the in-memory record on failure so it
471+
* matches what's still on disk.
364472
*/
365473
export function UnassignSummonHotkey(): $CancellablePromise<void> {
366474
return $Call.ByID(2578647287);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Page } from '@playwright/test'
2+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
3+
import { test, expect } from './fixtures/server'
4+
import { clickRowAction } from './inventoryRow'
5+
import {
6+
connectMCPClient, exportWorkflowViaMCP, findWorkflowIdByLabel,
7+
enableMCPWritesWithApprovalRequired, restoreMCPWriteDefaults,
8+
} from './mcpTestClient'
9+
10+
// Exercises the floating approval prompt's frontend at its hash route
11+
// (/#/approvalprompt, docs/goals/0023-attention-escalation.md item 1,
12+
// app/ApprovalPrompt.tsx) over real Go bindings (Wails3 server mode) --
13+
// same "the route itself is fully e2e-able headlessly" reasoning
14+
// quick-panel.spec.ts already established for the sibling second
15+
// window (ADR-0033's mechanism, reused here). What's NOT headlessly
16+
// verifiable is the WINDOW-LEVEL behavior around it (floating window
17+
// level, the backend-triggered Show() on an away verdict, Escape via
18+
// the native HideOnEscape option, the focus-yield mitigation) -- those
19+
// belong in the manual-only registry (.claude/skills/run-mill/SKILL.md)
20+
// per .claude/rules/testing.md's own "manual-only registry... never
21+
// silently absent" requirement.
22+
23+
function workflowRow(page: Page, label: string) {
24+
return page.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page.getByText(label, { exact: true }) })
25+
}
26+
27+
test('the approval prompt route renders standalone and shows nothing pending', async ({ page }) => {
28+
await page.goto('/#/approvalprompt')
29+
30+
const prompt = page.getByTestId('approval-prompt')
31+
await expect(prompt).toBeVisible()
32+
await expect(page.getByTestId('approval-prompt-empty')).toBeVisible()
33+
34+
// No sidebar/PageLayout chrome -- this is the dedicated minimal shell
35+
// (ApprovalPromptApp), not <App/>'s tree.
36+
await expect(page.getByRole('link', { name: 'Workflows' })).toHaveCount(0)
37+
})
38+
39+
test('a parked MCP write shows in the approval prompt, and Approve resolves it', async ({ page }, testInfo) => {
40+
await enableMCPWritesWithApprovalRequired(page)
41+
42+
// A source workflow to export/re-import (import always mints a new
43+
// ID -- the exact gated write this lifecycle protects).
44+
await page.getByRole('link', { name: 'Workflows' }).click()
45+
await page.getByTestId('new-workflow').click()
46+
await page.locator('[role="tabpanel"]:not([hidden])').last().getByLabel('Label').fill('E2E approval prompt source')
47+
await page.locator('[role="tabpanel"]:not([hidden])').last().getByTestId('save-workflow').click()
48+
await expect(workflowRow(page, 'E2E approval prompt source')).toBeVisible()
49+
50+
const client = await connectMCPClient(testInfo.parallelIndex)
51+
let importResultPromise: ReturnType<Client['callTool']>
52+
try {
53+
const sourceId = await findWorkflowIdByLabel(client, 'E2E approval prompt source')
54+
const exported = await exportWorkflowViaMCP(client, sourceId)
55+
56+
// Fire the gated import -- parks as a durable pending record
57+
// (docs/adr/0032), independent of whether this specific browser
58+
// page happens to be watching for it.
59+
importResultPromise = client.callTool({ name: 'import_workflow', arguments: { json: exported } })
60+
61+
// A fresh cross-document navigation to the approval prompt's own
62+
// route (not a same-document hash change from an already-loaded
63+
// page, same discipline quick-panel.spec.ts's own run-target test
64+
// uses) picks up the already-parked write on its first fetch.
65+
await page.goto('about:blank')
66+
await page.goto('/#/approvalprompt')
67+
68+
const description = page.getByTestId('approval-prompt-description')
69+
await expect(description).toBeVisible({ timeout: 15_000 })
70+
await expect(description).toContainText('import a workflow')
71+
72+
await page.getByTestId('approval-prompt-approve').click()
73+
74+
// Resolved -- the prompt goes back to its empty state (no more
75+
// pending items), which is also what drives the real window's own
76+
// auto-hide (DismissApprovalPrompt).
77+
await expect(page.getByTestId('approval-prompt-empty')).toBeVisible({ timeout: 10_000 })
78+
79+
// The write actually executed: a second workflow with the same
80+
// label now exists (import always mints a new ID, never overwrites).
81+
await page.goto('/')
82+
await page.getByRole('link', { name: 'Workflows' }).click()
83+
await expect(workflowRow(page, 'E2E approval prompt source')).toHaveCount(2, { timeout: 10_000 })
84+
85+
const result = await importResultPromise
86+
if (result.isError) {
87+
throw new Error(`import_workflow ultimately errored after approval: ${JSON.stringify(result.content)}`)
88+
}
89+
} finally {
90+
await client.close()
91+
}
92+
93+
// Cleanup: both minted workflows, one at a time (the DOM shifts as
94+
// each row disappears), and the settings toggle.
95+
let remaining = await workflowRow(page, 'E2E approval prompt source').count()
96+
while (remaining > 0) {
97+
await clickRowAction(page, workflowRow(page, 'E2E approval prompt source').first(), 'Delete')
98+
remaining -= 1
99+
await expect(workflowRow(page, 'E2E approval prompt source')).toHaveCount(remaining)
100+
}
101+
await restoreMCPWriteDefaults(page)
102+
})

frontend/e2e/mcpTestClient.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Page } from '@playwright/test'
2+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
4+
import { MCP_BASE_PORT, expect } from './fixtures/server'
5+
6+
// Shared e2e helpers for driving Mill's real MCP server over HTTP
7+
// (docs/adr/0025/0032) -- factored out of mcp-write-approval.spec.ts so
8+
// a second spec needing a parked MCP write (the Review kind filter,
9+
// docs/goals/0002 item 4) doesn't hand-roll a second copy. Not a
10+
// *.spec.ts file itself -- Playwright only picks up files matching its
11+
// own testMatch glob, same "plain helper module" shape as
12+
// inventoryRow.ts.
13+
14+
export async function connectMCPClient(workerIndex: number): Promise<Client> {
15+
const client = new Client({ name: 'mill-e2e', version: '0.0.0' })
16+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${MCP_BASE_PORT + workerIndex}`))
17+
await client.connect(transport)
18+
return client
19+
}
20+
21+
interface WorkflowIndexEntry {
22+
id: string
23+
label: string
24+
}
25+
26+
export async function findWorkflowIdByLabel(client: Client, label: string): Promise<string> {
27+
const result = await client.readResource({ uri: 'mill://workflows' })
28+
const first = result.contents[0]
29+
const text = 'text' in first ? first.text : ''
30+
const list = JSON.parse(text as string) as WorkflowIndexEntry[]
31+
const found = list.find((w) => w.label === label)
32+
if (!found) throw new Error(`workflow "${label}" not found in mill://workflows: ${text}`)
33+
return found.id
34+
}
35+
36+
export async function exportWorkflowViaMCP(client: Client, id: string): Promise<string> {
37+
const result = await client.callTool({ name: 'export_workflow', arguments: { id } })
38+
if (result.isError) throw new Error(`export_workflow failed: ${JSON.stringify(result.content)}`)
39+
const content = result.content as Array<{ type: string; text?: string }>
40+
return content[0]?.text ?? ''
41+
}
42+
43+
// Writes ON, per-write approval left ON (the default) -- the shape most
44+
// callers need, distinct from canvas-live-sync.spec.ts's own
45+
// "unattended" helper which deliberately relaxes approval.
46+
export async function enableMCPWritesWithApprovalRequired(page: Page): Promise<void> {
47+
await page.goto('/')
48+
await page.getByRole('button', { name: 'Settings' }).click()
49+
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
50+
await expect(writeCheckbox).toBeEnabled()
51+
if (!(await writeCheckbox.isChecked())) {
52+
await writeCheckbox.click()
53+
await expect(writeCheckbox).toBeChecked()
54+
}
55+
const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox')
56+
await expect(approvalCheckbox).toBeEnabled()
57+
if (!(await approvalCheckbox.isChecked())) {
58+
await approvalCheckbox.click()
59+
await expect(approvalCheckbox).toBeChecked()
60+
}
61+
}
62+
63+
// Leaves the shared e2e settings file the way a fresh install would
64+
// look (.claude/rules/testing.md's cleanup discipline): write gate off,
65+
// approval required stays on either way.
66+
export async function restoreMCPWriteDefaults(page: Page): Promise<void> {
67+
await page.goto('/')
68+
await page.getByRole('button', { name: 'Settings' }).click()
69+
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
70+
if (await writeCheckbox.isChecked()) {
71+
await writeCheckbox.click()
72+
await expect(writeCheckbox).not.toBeChecked()
73+
}
74+
}

0 commit comments

Comments
 (0)