Skip to content

Commit 98b8a10

Browse files
alicodingclaude
andcommitted
Add an in-app Recent activity feed for hotkey-triggered actions
A fired hotkey runs headlessly (writes straight to the clipboard, per §2.2) with zero UI feedback either way -- a correctly-firing hotkey and a silently swallowed one looked identical from the app itself. Emits a HotkeyActivity event (Wails typed event, same pattern as the existing footer clock) at each fire-path outcome; RunbookView subscribes and renders the last 5 as a capped, in-memory feed. Complements the terminal-only slog lines from the previous commit rather than replacing them -- this is the in-app view of the same information. Also parks a note in SPEC.md: RunbookView is up to 8 useState hooks and showing real scaling pressure, but a state-management library isn't warranted yet with only one stateful view -- revisit once a second view needs to share state with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7zUjYuMtgetjNaxMQPg2h
1 parent 01a95b5 commit 98b8a10

9 files changed

Lines changed: 128 additions & 1 deletion

File tree

docs/SPEC.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ and [`docs/adr/0002-cicd-pipeline-phased-rollout.md`](adr/0002-cicd-pipeline-pha
186186
- npm workspaces (`frontend/` + a future `browser-extension/`, §5) — not
187187
adopted yet, revisit when a real second JS package is scaffolded. `go.work`
188188
not applicable — single Go module is permanent per §1.1. `PARKED`
189+
- Frontend state management (Zustand or similar) — noted, not adopted.
190+
`RunbookView.tsx` is up to 8 `useState` hooks (actions, hotkey bindings,
191+
run results/errors, recording state, activity feed) and prop-drilling/
192+
scaling pressure is visible, but it's still one view with genuinely
193+
local state — reaching for a state library now would be the same
194+
premature-architecture mistake CLAUDE.md already warns against for the
195+
backend. Revisit once a second stateful view needs to share state with
196+
Runbook, not before. `PARKED`
189197
- CI: GitHub Actions, all four ADR-0002 phases shipped in
190198
`.github/workflows/ci.yml` + `.github/workflows/release.yml`.
191199
`golangci-lint` v2, ESLint flat config, Vitest, `go test -race -cover`,

frontend/bindings/github.com/alicoding/mill/hotkeyservice.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
* Runbook action. Assignments are in-memory only for now — they don't
77
* survive an app restart. Persistence is a deliberate follow-up, not
88
* built into this first pass.
9+
*
10+
* The fire path (OS delivers a keypress -> action runs -> clipboard is
11+
* written) has no UI surface at all, unlike the Run button's inline
12+
* success/error rendering — a hotkey that's registered but never fires
13+
* (e.g. the combo is already claimed by another app, or macOS just never
14+
* delivers it) is otherwise silent and undebuggable. logger makes every
15+
* stage of that path visible instead of guessing.
916
* @module
1017
*/
1118

frontend/bindings/github.com/alicoding/mill/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ export {
99
RunbookService,
1010
SpecService
1111
};
12+
13+
export type {
14+
HotkeyActivity
15+
} from "./models.js";
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* HotkeyActivity is emitted once a fired hotkey resolves (success or
6+
* failure). The Go-side slog lines (hotkeyservice.go) log the same
7+
* information for terminal/`task dev` visibility; this event is the
8+
* in-app equivalent, so a hotkey's outcome is visible without a
9+
* terminal — added after a real hotkey worked correctly (fired, ran,
10+
* wrote to the clipboard) but looked from the UI like nothing happened,
11+
* because nothing in the UI ever said otherwise.
12+
*/
13+
export interface HotkeyActivity {
14+
"actionID": string;
15+
"binding": string;
16+
"success": boolean;
17+
"detail": string;
18+
}

frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
// @ts-ignore: Unused imports
66
import type { Events } from "@wailsio/runtime";
77

8+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+
// @ts-ignore: Unused imports
10+
import type * as main$0 from "../../../../alicoding/mill/models.js";
11+
812
declare module "@wailsio/runtime" {
913
namespace Events {
1014
interface CustomEvents {
15+
"hotkey-activity": main$0.HotkeyActivity;
1116
"time": string;
1217
}
1318
}

frontend/public/style.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,8 @@ nav[aria-label="Mill"] {
197197
max-height: 240px;
198198
overflow-y: auto;
199199
}
200+
.runbook-activity-heading { margin-top: var(--s-4); }
201+
.runbook-activity-row {
202+
padding: var(--base-size-4) 0;
203+
border-bottom: 1px solid var(--borderColor-muted);
204+
}

frontend/src/RunbookView.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { useEffect, useState } from 'react'
2+
import { Events } from '@wailsio/runtime'
23
import { Button, Heading, Label, type LabelProps, SkeletonBox, Stack, Text } from '@primer/react'
3-
import { BeakerIcon, KeyIcon, MarkdownIcon } from '@primer/octicons-react'
4+
import { BeakerIcon, CheckCircleIcon, KeyIcon, MarkdownIcon, XCircleIcon } from '@primer/octicons-react'
45
import { RunbookService, HotkeyService } from '../bindings/github.com/alicoding/mill'
6+
import type { HotkeyActivity } from '../bindings/github.com/alicoding/mill/models'
57
import type { Action } from '../bindings/github.com/alicoding/mill/internal/domain/runbook/models'
68
import { keyFromEventCode, modsFromEvent } from './keybinding'
79

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+
818
// Per-action leading icon. Falls back to KeyIcon for any future action not
919
// listed here rather than rendering nothing.
1020
const ACTION_ICONS: Record<string, typeof BeakerIcon> = {
@@ -35,12 +45,20 @@ function RunbookView() {
3545
const [bindings, setBindings] = useState<Record<string, string>>({})
3646
const [bindingErrors, setBindingErrors] = useState<Record<string, string>>({})
3747
const [recordingId, setRecordingId] = useState<string | null>(null)
48+
const [activity, setActivity] = useState<(HotkeyActivity & { id: string; time: string })[]>([])
3849

3950
useEffect(() => {
4051
RunbookService.List().then((list) => setActions(list ?? [])).catch(console.error)
4152
HotkeyService.List().then((list) => setBindings((list ?? {}) as Record<string, string>)).catch(console.error)
4253
}, [])
4354

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+
4462
useEffect(() => {
4563
if (!recordingId) return
4664

@@ -165,8 +183,36 @@ function RunbookView() {
165183
})}
166184
</Stack>
167185
)}
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+
)}
168210
</div>
169211
)
170212
}
171213

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

hotkeyservice.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/alicoding/mill/internal/adapters/clipboard"
1111
"github.com/alicoding/mill/internal/adapters/hotkey"
12+
"github.com/wailsapp/wails/v3/pkg/application"
1213
)
1314

1415
var modSymbol = map[string]string{
@@ -83,13 +84,16 @@ func (h *HotkeyService) Assign(actionID string, mods []string, key string) (stri
8384
result, err := h.runbook.Run(actionID)
8485
if err != nil {
8586
h.logger.Error("hotkey action failed", "action", actionID, "binding", label, "error", err)
87+
emitHotkeyActivity(actionID, label, false, err.Error())
8688
continue
8789
}
8890
if err := clipboard.WriteText(result); err != nil {
8991
h.logger.Error("hotkey result clipboard write failed", "action", actionID, "binding", label, "error", err)
92+
emitHotkeyActivity(actionID, label, false, "clipboard write failed: "+err.Error())
9093
continue
9194
}
9295
h.logger.Info("hotkey action completed", "action", actionID, "binding", label, "output_bytes", len(result))
96+
emitHotkeyActivity(actionID, label, true, fmt.Sprintf("copied to clipboard (%d bytes)", len(result)))
9397
}
9498
}()
9599

@@ -125,3 +129,18 @@ func formatBinding(mods []string, key string) string {
125129
b.WriteString(strings.ToUpper(key))
126130
return b.String()
127131
}
132+
133+
// emitHotkeyActivity pushes a HotkeyActivity event to the frontend so a
134+
// fired hotkey's outcome is visible in the app itself, not just in the
135+
// slog lines above (terminal-only, and only during `task dev`).
136+
// application.Get() is safe to call here: this only ever runs from the
137+
// Keydown() goroutine, which can't fire before application.New has run
138+
// and registered the global app instance.
139+
func emitHotkeyActivity(actionID, binding string, success bool, detail string) {
140+
application.Get().Event.Emit("hotkey-activity", HotkeyActivity{
141+
ActionID: actionID,
142+
Binding: binding,
143+
Success: success,
144+
Detail: detail,
145+
})
146+
}

main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,26 @@ import (
1818
//go:embed all:frontend/dist
1919
var assets embed.FS
2020

21+
// HotkeyActivity is emitted once a fired hotkey resolves (success or
22+
// failure). The Go-side slog lines (hotkeyservice.go) log the same
23+
// information for terminal/`task dev` visibility; this event is the
24+
// in-app equivalent, so a hotkey's outcome is visible without a
25+
// terminal — added after a real hotkey worked correctly (fired, ran,
26+
// wrote to the clipboard) but looked from the UI like nothing happened,
27+
// because nothing in the UI ever said otherwise.
28+
type HotkeyActivity struct {
29+
ActionID string `json:"actionID"`
30+
Binding string `json:"binding"`
31+
Success bool `json:"success"`
32+
Detail string `json:"detail"`
33+
}
34+
2135
func init() {
2236
// Register a custom event whose associated data type is string.
2337
// This is not required, but the binding generator will pick up registered events
2438
// and provide a strongly typed JS/TS API for them.
2539
application.RegisterEvent[string]("time")
40+
application.RegisterEvent[HotkeyActivity]("hotkey-activity")
2641
}
2742

2843
// main function serves as the application's entry point. It initializes the application, creates a window,

0 commit comments

Comments
 (0)