Skip to content

Commit 156b9ad

Browse files
alicodingclaude
andcommitted
Dedicated Activity page, Accessibility deep-link button, and a dev-mode ribbon
Recent activity moves out of RunbookView into its own always-visible page (ActivityView.tsx) -- nested inside Runbook and gated on activity.length > 0, it was indistinguishable from "the feed doesn't work" when nothing had fired yet. The Events.On('hotkey-activity', ...) subscription now lives in App.tsx so it keeps collecting regardless of which tab is open. actions is lifted to App.tsx too, passed down to both RunbookView and ActivityView, rather than each view fetching its own copy. RunbookView's Accessibility-permission bindingError now shows an "Open Accessibility Settings" button (Browser.OpenURL, not data-wml-openURL -- the button only exists post-error, well after WML's one-time mount-time scan) pointing at a deep link verified directly on this machine (macOS 26.5.2): opens Privacy & Security -> Accessibility, not just System Settings' default screen. Also adds a small dev-mode ribbon (top-right, Primer Label) gated on import.meta.env.DEV, showing "DEV · loaded HH:MM:SS". Verified empirically which Vite env flag is actually reliable here: DEV is false for both `vite build --mode development` and `vite build --mode production` (both go through the build command, not serve) and only true for a genuine `vite serve` process -- which is what task dev's window actually renders through. Using MODE instead would have been silently wrong. SPEC.md records the verified Accessibility deep-link identifier and a newly root-caused gotcha: dev builds are freshly ad-hoc code-signed on every rebuild, so macOS treats each one as a new app and re-asks for Accessibility permission -- not a Mill bug, but real, recurring local dev-loop friction worth not re-debugging from scratch next time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7zUjYuMtgetjNaxMQPg2h
1 parent 98b8a10 commit 156b9ad

5 files changed

Lines changed: 180 additions & 69 deletions

File tree

docs/SPEC.md

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -339,20 +339,38 @@ that environment, on something testable directly in this dev session:
339339
next build step.
340340
- **Permissions UX pattern for when this needs Accessibility access**:
341341
macOS supports deep-linking straight into a specific System Settings pane
342-
via the `x-apple.systempreferences:` URL scheme (`open
343-
"x-apple.systempreferences:com.apple.Keyboard-Settings.extension?Shortcuts"`,
344-
confirmed via search) — this is the exact mechanism Hammerspoon itself
345-
uses for its own "grant Accessibility permission" prompt, and Mill will
346-
need the same prompt once hotkeys/simulated-paste land. Caveat: this is
347-
not an official documented Apple API — it's community-reverse-engineered,
348-
and identifiers have broken before across macOS System Settings rewrites
349-
(confirmed: the pre-Ventura Accessibility deep-link stopped working when
350-
Ventura rebuilt System Settings). Use it, but with a plain-language
351-
fallback for when the link silently stops landing correctly — don't trust
352-
it blindly, and verify the exact identifier against the target macOS
353-
version at build time rather than assuming it still holds. `OPEN`
354-
(exact identifiers, verify at implementation time) / `LOCKED` (the
355-
show-current-state-and-deep-link pattern itself is worth using).
342+
via the `x-apple.systempreferences:` URL scheme — this is the exact
343+
mechanism Hammerspoon/Raycast/1Password all use for their own "grant
344+
Accessibility permission" prompts. `RunbookView.tsx`'s Accessibility
345+
error now shows an "Open Accessibility Settings" button (`Browser.OpenURL`
346+
from `@wailsio/runtime`, not `data-wml-openURL` — the button only exists
347+
once a bindingError fires, well after WML's one-time mount-time DOM scan,
348+
so the imperative call is used instead of the declarative tag to avoid a
349+
wire-up timing gap) pointing at
350+
`x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`
351+
**verified directly on this machine (macOS 26.5.2/25F84), not assumed**:
352+
ran it via `open`, confirmed by the user it landed on Privacy & Security →
353+
Accessibility, not just System Settings' default screen. Caveat still
354+
stands: this is not an official documented Apple API — it's
355+
community-reverse-engineered, and identifiers have broken before across
356+
macOS System Settings rewrites (the pre-Ventura Accessibility deep-link
357+
stopped working when Ventura rebuilt System Settings), so re-verify
358+
against the target macOS version if this stops landing correctly after a
359+
future update. `LOCKED` (identifier verified + wired up for macOS 26;
360+
the show-current-state-and-deep-link pattern itself).
361+
- **Dev builds re-trigger the Accessibility grant on every rebuild**
362+
root-caused a real "a hotkey registered and fired cleanly, then after
363+
the next `task dev` restart the identical combo failed to register at
364+
all" confusion. `build/darwin/Taskfile.yml`'s dev-build task runs
365+
`codesign --force --deep --sign -` (ad-hoc signing) on every single
366+
build, which changes `bin/mill.dev.app`'s code identity each time —
367+
macOS's TCC ties an Accessibility grant to that identity, so every dev
368+
rebuild looks like a brand-new, ungranted app to TCC. Not a bug in
369+
Mill's own code; a known category of friction with ad-hoc-signed local
370+
dev builds. No fix implemented (a stable local signing identity would
371+
need its own investigation) — noted here so it isn't re-debugged from
372+
scratch next time it's hit. `LOCKED` (the root cause) / `OPEN` (whether
373+
it's worth a fix, e.g. a consistent local dev signing identity).
356374
- **Progressive enhancement by permission, not a hard gate.** `LOCKED`
357375
Zero-permission floor: browsing the Runbook and running an action by
358376
clicking it always works, no OS permission required. Accessibility

frontend/public/style.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ body {
114114
.footer-time svg { width: 13px; height: 13px; }
115115
}
116116

117+
/* Dev-mode ribbon: only rendered when import.meta.env.DEV is true (App.tsx),
118+
so this rule is inert/unused in any real build. */
119+
.dev-ribbon {
120+
--wails-draggable: no-drag;
121+
position: fixed;
122+
top: var(--base-size-8);
123+
right: var(--base-size-8);
124+
z-index: 2;
125+
}
126+
117127
/* View switcher (Runbook / Spec) is Primer's UnderlineNav — no bespoke CSS needed. */
118128
nav[aria-label="Mill"] {
119129
--wails-draggable: no-drag;
@@ -197,7 +207,6 @@ nav[aria-label="Mill"] {
197207
max-height: 240px;
198208
overflow-y: auto;
199209
}
200-
.runbook-activity-heading { margin-top: var(--s-4); }
201210
.runbook-activity-row {
202211
padding: var(--base-size-4) 0;
203212
border-bottom: 1px solid var(--borderColor-muted);

frontend/src/ActivityView.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Heading, Label, Stack, Text } from '@primer/react'
2+
import { CheckCircleIcon, XCircleIcon } from '@primer/octicons-react'
3+
import type { HotkeyActivity } from '../bindings/github.com/alicoding/mill/models'
4+
import type { Action } from '../bindings/github.com/alicoding/mill/internal/domain/runbook/models'
5+
6+
export type ActivityEntry = HotkeyActivity & { id: string; time: string }
7+
8+
interface ActivityViewProps {
9+
activity: ActivityEntry[]
10+
actions: Action[] | null
11+
}
12+
13+
function actionName(actions: Action[] | null, actionID: string): string {
14+
return actions?.find((a) => a.ID === actionID)?.Name ?? actionID
15+
}
16+
17+
// A dedicated, always-visible page rather than a section tucked inside
18+
// Runbook: a hotkey fires headlessly with no other UI surface (§2.2), so
19+
// this is the only way to see whether anything fired at all — nested
20+
// inside another page, it was indistinguishable from "the feed doesn't
21+
// work" when nothing had fired yet. Subscribed once at App.tsx (not here)
22+
// so it keeps collecting even while this tab isn't the active view.
23+
function ActivityView({ activity, actions }: ActivityViewProps) {
24+
return (
25+
<div className="runbook">
26+
<Heading as="h1">Activity</Heading>
27+
<Text as="p" className="runbook-subtitle">
28+
What fired hotkeys actually did, in real time — hotkey triggers run
29+
headlessly and write straight to the clipboard, with no other
30+
feedback.
31+
</Text>
32+
33+
{activity.length === 0 && (
34+
<div className="runbook-empty">
35+
<Text as="p">No activity yet — press a bound hotkey to see it appear here.</Text>
36+
</div>
37+
)}
38+
39+
{activity.length > 0 && (
40+
<Stack direction="vertical" gap="condensed">
41+
{activity.map((entry) => (
42+
<Stack key={entry.id} direction="horizontal" align="center" gap="condensed" className="runbook-activity-row">
43+
{entry.success ? (
44+
<CheckCircleIcon size={16} fill="var(--fgColor-success)" />
45+
) : (
46+
<XCircleIcon size={16} fill="var(--fgColor-danger)" />
47+
)}
48+
<Text size="small" className="runbook-muted">{entry.time}</Text>
49+
<Label variant="secondary" size="small">{entry.binding}</Label>
50+
<Text size="small">{actionName(actions, entry.actionID)}</Text>
51+
<Text size="small" className="runbook-muted">{entry.detail}</Text>
52+
</Stack>
53+
))}
54+
</Stack>
55+
)}
56+
</div>
57+
)
58+
}
59+
60+
export default ActivityView

frontend/src/App.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,36 @@
11
import { useState, useEffect } from 'react'
22
import {Events, WML} from "@wailsio/runtime";
3-
import {UnderlineNav} from "@primer/react";
3+
import {Label, UnderlineNav} from "@primer/react";
44
import SpecView from "./SpecView";
55
import RunbookView from "./RunbookView";
6+
import ActivityView, { type ActivityEntry } from "./ActivityView";
7+
import { RunbookService } from "../bindings/github.com/alicoding/mill";
8+
import type { Action } from "../bindings/github.com/alicoding/mill/internal/domain/runbook/models";
69

710
// Show the actual Wails version this project was generated against.
811
const wailsVersion = "v3.0.0-beta.4";
912

13+
const MAX_ACTIVITY_ENTRIES = 50;
14+
15+
// import.meta.env.DEV is Vite's own built-in flag, not something Mill
16+
// wires up itself: true only for a real `vite serve` process (what
17+
// `task dev`'s window actually renders through, per devServerURL in its
18+
// logs), false for every `vite build` output regardless of --mode --
19+
// verified directly, not assumed, since that distinction is easy to get
20+
// backwards. This is Mill's answer to "am I looking at a dev build,
21+
// and is it current" (see docs/SPEC.md's dev-build/hot-reload notes).
22+
const isDevBuild = import.meta.env.DEV;
23+
1024
function App() {
11-
const [view, setView] = useState<'spec' | 'runbook'>('runbook');
25+
const [view, setView] = useState<'spec' | 'runbook' | 'activity'>('runbook');
1226
const [time, setTime] = useState<string>('Listening for Time event...');
27+
const [actions, setActions] = useState<Action[] | null>(null);
28+
const [activity, setActivity] = useState<ActivityEntry[]>([]);
29+
// Captured once per mount. A Go-file change forces a full app reload
30+
// (Go isn't hot-reloadable, unlike frontend-only edits which apply via
31+
// Vite HMR without remounting) -- so this timestamp doubles as "when
32+
// did the last Go rebuild actually land," not just page-load trivia.
33+
const [loadedAt] = useState(() => new Date().toLocaleTimeString());
1334

1435
useEffect(() => {
1536
Events.On('time', (timeValue) => {
@@ -23,18 +44,44 @@ function App() {
2344
WML.Reload();
2445
}, []);
2546

47+
useEffect(() => {
48+
RunbookService.List().then((list) => setActions(list ?? [])).catch(console.error);
49+
}, []);
50+
51+
// Subscribed here, not inside ActivityView/RunbookView, so a hotkey
52+
// fired while on a different tab is still captured -- the whole point
53+
// of this feed is answering "did anything fire at all" regardless of
54+
// which page happened to be open at the time.
55+
useEffect(() => {
56+
return Events.On('hotkey-activity', (evt) => {
57+
const entry = { ...evt.data, id: crypto.randomUUID(), time: new Date().toLocaleTimeString() };
58+
setActivity((prev) => [entry, ...prev].slice(0, MAX_ACTIVITY_ENTRIES));
59+
});
60+
}, []);
61+
2662
return (
2763
<>
64+
{isDevBuild && (
65+
<Label variant="severe" size="small" className="dev-ribbon">
66+
DEV · loaded {loadedAt}
67+
</Label>
68+
)}
69+
2870
<UnderlineNav aria-label="Mill">
2971
<UnderlineNav.Item aria-current={view === 'runbook' ? 'page' : undefined} onSelect={(e) => { e.preventDefault(); setView('runbook') }}>
3072
Runbook
3173
</UnderlineNav.Item>
74+
<UnderlineNav.Item aria-current={view === 'activity' ? 'page' : undefined} onSelect={(e) => { e.preventDefault(); setView('activity') }}>
75+
Activity
76+
</UnderlineNav.Item>
3277
<UnderlineNav.Item aria-current={view === 'spec' ? 'page' : undefined} onSelect={(e) => { e.preventDefault(); setView('spec') }}>
3378
Spec
3479
</UnderlineNav.Item>
3580
</UnderlineNav>
3681

37-
{view === 'runbook' && <RunbookView/>}
82+
{view === 'runbook' && <RunbookView actions={actions}/>}
83+
84+
{view === 'activity' && <ActivityView activity={activity} actions={actions}/>}
3885

3986
{view === 'spec' && <SpecView/>}
4087

frontend/src/RunbookView.tsx

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
import { useEffect, useState } from 'react'
2-
import { Events } from '@wailsio/runtime'
2+
import { Browser } from '@wailsio/runtime'
33
import { Button, Heading, Label, type LabelProps, SkeletonBox, Stack, Text } from '@primer/react'
4-
import { BeakerIcon, CheckCircleIcon, KeyIcon, MarkdownIcon, XCircleIcon } from '@primer/octicons-react'
4+
import { BeakerIcon, KeyIcon, MarkdownIcon } from '@primer/octicons-react'
55
import { RunbookService, HotkeyService } from '../bindings/github.com/alicoding/mill'
6-
import type { HotkeyActivity } from '../bindings/github.com/alicoding/mill/models'
76
import type { Action } from '../bindings/github.com/alicoding/mill/internal/domain/runbook/models'
87
import { keyFromEventCode, modsFromEvent } from './keybinding'
98

10-
// A fired hotkey has no other UI surface — it runs headlessly and writes
11-
// straight to the clipboard (§2.2). Without this feed, a correctly firing
12-
// hotkey and a silently swallowed one look identical from the UI: nothing
13-
// visibly happens either way. Capped and in-memory only, same as the
14-
// bindings themselves — see SPEC.md §2.2's "Hotkey fire path is logged
15-
// end-to-end" entry.
16-
const MAX_ACTIVITY_ENTRIES = 5
17-
189
// Per-action leading icon. Falls back to KeyIcon for any future action not
1910
// listed here rather than rendering nothing.
2011
const ACTION_ICONS: Record<string, typeof BeakerIcon> = {
@@ -37,28 +28,35 @@ function reachTier(label: string): { text: string; variant: LabelProps['variant'
3728
return { text: 'Deliberately awkward', variant: 'danger' }
3829
}
3930

40-
function RunbookView() {
41-
const [actions, setActions] = useState<Action[] | null>(null)
31+
// SPEC.md §2.2's "Permissions UX pattern" — deep-link straight into the
32+
// exact System Settings pane instead of telling the user to go find it
33+
// themselves, same pattern Hammerspoon/Raycast/1Password use. Verified
34+
// directly against this machine's actual macOS version (26.5.2) rather
35+
// than assumed — these identifiers are unofficial and have broken across
36+
// System Settings rewrites before (see SPEC.md), so re-verify if this
37+
// stops landing on the right pane after a macOS update.
38+
const ACCESSIBILITY_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
39+
40+
function isAccessibilityError(message: string): boolean {
41+
return message.includes('Accessibility')
42+
}
43+
44+
interface RunbookViewProps {
45+
actions: Action[] | null
46+
}
47+
48+
function RunbookView({ actions }: RunbookViewProps) {
4249
const [runningId, setRunningId] = useState<string | null>(null)
4350
const [results, setResults] = useState<Record<string, string>>({})
4451
const [errors, setErrors] = useState<Record<string, string>>({})
4552
const [bindings, setBindings] = useState<Record<string, string>>({})
4653
const [bindingErrors, setBindingErrors] = useState<Record<string, string>>({})
4754
const [recordingId, setRecordingId] = useState<string | null>(null)
48-
const [activity, setActivity] = useState<(HotkeyActivity & { id: string; time: string })[]>([])
4955

5056
useEffect(() => {
51-
RunbookService.List().then((list) => setActions(list ?? [])).catch(console.error)
5257
HotkeyService.List().then((list) => setBindings((list ?? {}) as Record<string, string>)).catch(console.error)
5358
}, [])
5459

55-
useEffect(() => {
56-
return Events.On('hotkey-activity', (evt) => {
57-
const entry = { ...evt.data, id: crypto.randomUUID(), time: new Date().toLocaleTimeString() }
58-
setActivity((prev) => [entry, ...prev].slice(0, MAX_ACTIVITY_ENTRIES))
59-
})
60-
}, [])
61-
6260
useEffect(() => {
6361
if (!recordingId) return
6462

@@ -170,7 +168,14 @@ function RunbookView() {
170168
</Stack>
171169

172170
{bindingErrors[action.ID] && (
173-
<Text as="p" size="small" className="runbook-error">{bindingErrors[action.ID]}</Text>
171+
<Stack direction="vertical" gap="condensed">
172+
<Text as="p" size="small" className="runbook-error">{bindingErrors[action.ID]}</Text>
173+
{isAccessibilityError(bindingErrors[action.ID]) && (
174+
<Button size="small" onClick={() => Browser.OpenURL(ACCESSIBILITY_SETTINGS_URL)}>
175+
Open Accessibility Settings
176+
</Button>
177+
)}
178+
</Stack>
174179
)}
175180
{errors[action.ID] && (
176181
<Text as="p" size="small" className="runbook-error">{errors[action.ID]}</Text>
@@ -183,36 +188,8 @@ function RunbookView() {
183188
})}
184189
</Stack>
185190
)}
186-
187-
{activity.length > 0 && (
188-
<>
189-
<Heading as="h2" variant="small" className="runbook-activity-heading">Recent activity</Heading>
190-
<Text as="p" size="small" className="runbook-muted runbook-subtitle">
191-
What fired hotkeys actually did — hotkey triggers run headlessly with no other feedback.
192-
</Text>
193-
<Stack direction="vertical" gap="condensed">
194-
{activity.map((entry) => (
195-
<Stack key={entry.id} direction="horizontal" align="center" gap="condensed" className="runbook-activity-row">
196-
{entry.success ? (
197-
<CheckCircleIcon size={16} fill="var(--fgColor-success)" />
198-
) : (
199-
<XCircleIcon size={16} fill="var(--fgColor-danger)" />
200-
)}
201-
<Text size="small" className="runbook-muted">{entry.time}</Text>
202-
<Label variant="secondary" size="small">{entry.binding}</Label>
203-
<Text size="small">{actionName(actions, entry.actionID)}</Text>
204-
<Text size="small" className="runbook-muted">{entry.detail}</Text>
205-
</Stack>
206-
))}
207-
</Stack>
208-
</>
209-
)}
210191
</div>
211192
)
212193
}
213194

214-
function actionName(actions: Action[] | null, actionID: string): string {
215-
return actions?.find((a) => a.ID === actionID)?.Name ?? actionID
216-
}
217-
218195
export default RunbookView

0 commit comments

Comments
 (0)