Skip to content

Commit af6efdb

Browse files
alicodingclaude
andauthored
feat: workflow pins/favorites (BACKLOG Standing #5) (#50)
A plain ordered workflow-ID list (shared/store.ts's pinnedWorkflowIds, persisted via the existing zustand `persist` localStorage tier, same pattern as goal 0033's activeWorkTabKey) with a togglePinnedWorkflow action. app/workflowFrecency.ts gains sortWorkflowsByPinnedAndFrecency, partitioning pinned rows (in pin order) above the existing frecency-sorted unpinned tail rather than a second ranking algorithm. A subtle Primer PinIcon toggle lands on both the Quick Panel's and the ⌘K palette's workflow rows -- muted outline unpinned, accent-colored once pinned. Along the way, found and fixed a real Primer interaction bug: ActionList.Item's TrailingVisual wraps children in a VisualWrap span with pointer-events: none (trailing visuals are decorative-only by the library's own convention), which silently ate every click on the toggle until pointer-events: auto was added back on the button itself. Vitest covers the pinned-above-frecency/pin-order/dropped-stale-id/ no-mutation cases; quick-panel.spec.ts gained a full pin-sorts-above-frecency -> unpin-reverts -> persists-across-reload e2e case. Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a6d65d2 commit af6efdb

11 files changed

Lines changed: 302 additions & 22 deletions

File tree

docs/SPEC.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2658,8 +2658,15 @@ findings) and the build rationale are in
26582658
Configure tab (`OpenMainWindow('configure:<tab>')`, a new
26592659
`app/useMillNavigate.ts` hook parsing the target, `ConfigureView`'s
26602660
new `initialTab` prop) — goal 0015's own remaining Acceptance
2661-
halves, see that goal file for what's still open (pins, the ⌘K
2662-
palette's own inline-hotkey-per-row detail, a ⌘?/⌘/ alias).
2661+
halves, see that goal file for what's still open (the ⌘K palette's
2662+
own inline-hotkey-per-row detail, a ⌘?/⌘/ alias). **Update
2663+
(2026-08-13, BACKLOG.md Standing #5)**: pins/favorites landed —
2664+
`pinnedWorkflowIds: string[]` on `shared/store.ts`'s existing
2665+
persisted store (localStorage tier, same as `activeWorkTabKey`), a
2666+
Primer `PinIcon` toggle on both the Quick Panel's and this ⌘K
2667+
palette's workflow rows, `app/workflowFrecency.ts`'s
2668+
`sortWorkflowsByPinnedAndFrecency` sorting pinned rows (in pin order)
2669+
above the existing frecency-sorted tail in both surfaces.
26632670
- **Auto-update**`app.Updater` (Wails3's own first-party,
26642671
zero-new-dependency `v3/pkg/updater`) is `Init`'d in `main.go` with a
26652672
GitHub Releases provider pointed at `alicoding/mill`;
@@ -2713,8 +2720,8 @@ findings) and the build rationale are in
27132720
halves — landed into the **Quick Panel** (`app/QuickPanel.tsx`,
27142721
ADR-0033), not this ⌘K palette: frequency-only workflow sort off
27152722
goal 0014's `HomeMetrics.mostUsed` (`app/workflowFrecency.ts`; pins
2716-
stayed out — no pin/favorite concept exists anywhere in Mill, a
2717-
BACKLOG.md tech-debt line rather than invented schema), connector/
2723+
landed later as their own BACKLOG.md tech-debt line — see the
2724+
Update above), connector/
27182725
List/MCP-Server rows that jump the main window to the right
27192726
Configure tab (`SettingsService.OpenMainWindow('configure:<tab>')`
27202727
`app/useMillNavigate.ts``ConfigureView`'s `initialTab`), and

docs/goals/BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ live-review material, interleaved during owner reviews, not a lane.**
199199
2. [x] [0039 — Clipboard apply](archive/0039-clipboard-apply.md) — DELIVERED 2026-08-12, owner-driven, slotted in right after the flake fix: "Apply from clipboard…" in the Quick Panel (`app/QuickPanelClipboardApply.tsx`), the enterprise-critical door (MCP is deny-all at a locked-down enterprise environment) — reuses the existing export/import JSON format unchanged (structure-sniffed, workflow only this goal), `exportedWorkflow` gains an optional `id` accepted-on-import (export side still never emits it) driving create-vs-update through the same `SnapshotDraft`+`UpdateWorkflowFromExport` chokepoint `update_workflow` uses, preview-confirm (not park-and-poll — ADR-0032 deliberately doesn't gate this path, the invocation IS the human present), non-blocking dangling-reference + unknown-NodeTypeID surfacing (`composition.RefExists`, the sharing-research import-then-fix verdict). `panel.applyClipboard` command registered (no default binding). Go unit + 4 e2e cases (create/update/malformed/dangling-ref) all green; SPEC.md's "one API, many doors" line added (§3.6).
200200
3. [x] [0031 — AI node family](archive/0031-ai-node-family.md) — DELIVERED 2026-08-12, the flagship capability, shipped as two sequential PRs for reviewability. PR1: `internal/domain/aiprovider` (the `AIProvider` Configure entity, mirrors MCPServer's recipe) + `internal/adapters/aiclient` (`openaicompat` covers Ollama's own `/v1` shim + LM Studio/vLLM/any BYO endpoint, `anthropic` speaks the native Messages API not its OpenAI-compat shim — both httptest-proven) + `process-ai-completion`, plus a pre-flight audit of every registered NodeType's ConfigFields against the Configure-vs-workflow split (verdict: already fully consistent — codified in `.claude/rules/architecture.md`). PR2: `process-ai-extract-structured` (own typed output-field editor, `AIExtractFieldsEditor.tsx`) + `process-ai-classify` (node-local category list, fail-safe on an out-of-enum response) + the "Example: AI classify -> branch" seed — THE decisioning composition (AI writes a category Attribute, Branch routes on it). Effect: static `ClassExternal` on all three, `EffectForNode` downgrades to `ClassLocal` for a loopback AIProvider BaseURL (owner-ratified 2026-08-12: "you're the boss") — remote asks by default, local Ollama frictionless. Conforms to node-standard.md from birth; `docs/SPEC.md` §3.3 flipped OPEN → LOCKED.
201201
4. [x] Copy-management migration ×4 (`app/``composition/``configure/``views/`) then the `eslint-plugin-i18next` revisit — DELIVERED 2026-08-12, see item 10's own writeup above ([0032](archive/0032-copy-management.md)).
202-
5. [ ] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — schema DECIDED at prioritization (orchestrator, 2026-08-12): a plain ordered workflow-ID list, store-owned (localStorage-tier alongside the frecency substrate; no per-workflow field, no new Go surface unless syncing matters later). Pinned rows sort above frecency in Quick Panel/⌘K.
202+
5. [x] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — DELIVERED 2026-08-13: `pinnedWorkflowIds: string[]` + `togglePinnedWorkflow` on `shared/store.ts`'s existing zustand `persist` (same localStorage tier as `activeWorkTabKey`, goal 0033's precedent — no new Go surface). `app/workflowFrecency.ts`'s new `sortWorkflowsByPinnedAndFrecency` partitions pinned (in pin-order) above the existing frecency-sorted unpinned tail, reusing `sortWorkflowsByFrecency` rather than a second algorithm. A Primer `PinIcon` `IconButton` trailing-visual pin toggle on both the Quick Panel's and ⌘K palette's workflow rows (muted outline unpinned, accent-colored "filled" once pinned) — found and fixed a real Primer interaction bug along the way: `ActionList.Item`'s own `TrailingVisual` wraps children in a `VisualWrap` span with `pointer-events: none` (trailing visuals are decorative-only by the library's own convention), which silently ate every click on the toggle until `pointer-events: auto` was added back on the button itself. Vitest covers the pinned-above-frecency/pin-order/unpinned-id-dropped/no-mutation cases; `quick-panel.spec.ts` gained a full pin→sort→unpin→revert→reload-persists e2e case.
203203
6. [ ] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — schema DECIDED at prioritization (orchestrator, 2026-08-12): `Command` grows an optional `extraBindings: KeyCombo[]` alongside the existing 1:1 `defaultBinding` (backward-compatible; Settings' rebinding UI edits the primary, extras render as secondary chips; dispatch checks all).
204204
7. [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) Phase 2: orchestrator-driven live MCP probing against the locked-down-enterprise use cases (the mandate names the orchestrator as the prober — self-driveable, exploratory; produces the next ranked gap list). Phase 3 judgments that need the owner surface as they're found.
205205
8. [ ] Dev-loop instance guards (tech debt, owner-hit 2026-08-12 evening: THREE concurrent `mill.dev.app` instances in the dock, real crash risk on the 16GB machine) — two confirmed root causes, both get mechanical fixes: (a) `wails3 dev`'s Go-rebuild cycle relaunches the app WITHOUT killing the previous instance, so orphans accumulate one per rebuild during heavy agent waves — extend goal 0029's start-sweep into a per-rebuild reap (kill any existing `bin/mill.dev.app` process before the new launch; find the right hook in the Taskfile's dev target or wails3 dev's own lifecycle); (b) `task dev` ran twice concurrently (two backgrounded watchers, each with its own app+vite) — the dev target refuses to start when an instance is already running (vite-port 9245 check + wails3-dev process check, clear message naming the existing PID). This makes the standing never-two-data-sharing-instances rule ENFORCED instead of remembered.

frontend/e2e/quick-panel.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,91 @@ test('a parked MCP write bumps the Quick Panel review badge live, no reload', as
291291
}
292292
await restoreMCPWriteDefaults(page)
293293
})
294+
295+
// docs/goals/BACKLOG.md Standing #5 -- workflow pins/favorites.
296+
// workflowFrecency.test.ts already covers the pure
297+
// sortWorkflowsByPinnedAndFrecency function; this proves the live
298+
// wiring end to end: pinning via the panel row's own toggle overrides
299+
// frecency ranking, unpinning reverts to it, and the pin survives a
300+
// reload (the localStorage-tier persistence the schema calls for).
301+
test('pinning a workflow from the panel row sorts it above frecency, unpinning reverts, and the pin persists across reload', async ({ page }) => {
302+
const pinnedLabel = 'ZzE2ePinTargetPinned'
303+
const frequentLabel = 'ZzE2ePinTargetFrequent'
304+
// frequentLabel created FIRST, pinnedLabel second -- so absent any
305+
// pin, frequentLabel's own run count would already outrank
306+
// pinnedLabel (same "real proof, not a coincidental pass" discipline
307+
// as the frecency test above).
308+
await createSimpleWorkflow(page, frequentLabel)
309+
await createSimpleWorkflow(page, pinnedLabel)
310+
311+
await page.goto('about:blank')
312+
await page.goto('/#/quickpanel')
313+
const search = page.getByRole('combobox', { name: 'Quick Panel search' })
314+
await expect(search).toBeFocused()
315+
316+
// Build up frequentLabel's frecency via the panel's own Enter-to-run
317+
// path, same as the frecency test above.
318+
for (let i = 0; i < 2; i++) {
319+
await search.fill(frequentLabel)
320+
await expect(page.getByRole('option', { name: frequentLabel })).toBeVisible()
321+
await page.keyboard.press('Enter')
322+
await expect(page.getByTestId('quick-panel-status')).toContainText(`Started "${frequentLabel}"`)
323+
await search.fill('')
324+
}
325+
326+
const orderedLabels = async () => {
327+
await search.fill('ZzE2ePinTarget')
328+
const texts = await page.getByRole('option').allTextContents()
329+
return texts
330+
}
331+
332+
// Before pinning: DBOS run history becomes queryable shortly after
333+
// RunWorkflow returns, not necessarily synchronously -- retry the
334+
// fresh-mount reload + order check rather than a fixed sleep, same
335+
// as the frecency test.
336+
await expect(async () => {
337+
await page.goto('about:blank')
338+
await page.goto('/#/quickpanel')
339+
await expect(search).toBeFocused()
340+
const texts = await orderedLabels()
341+
const frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
342+
const pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
343+
expect(frequentIndex).toBeGreaterThanOrEqual(0)
344+
expect(pinnedIndex).toBeGreaterThanOrEqual(0)
345+
expect(frequentIndex).toBeLessThan(pinnedIndex)
346+
}).toPass({ timeout: 15_000 })
347+
348+
// Pin the never-run workflow via its row's pin toggle -- it should
349+
// now sort ABOVE the frequently-run one despite having zero runs.
350+
await search.fill(pinnedLabel)
351+
await expect(page.getByRole('option', { name: pinnedLabel })).toBeVisible()
352+
await page.getByRole('button', { name: `Pin "${pinnedLabel}"` }).click()
353+
await expect(page.getByRole('button', { name: `Unpin "${pinnedLabel}"` })).toBeVisible()
354+
355+
let texts = await orderedLabels()
356+
let frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
357+
let pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
358+
expect(pinnedIndex).toBeLessThan(frequentIndex)
359+
360+
// Persists across reload: a fresh mount of the same window still
361+
// shows the pin above frecency, with no re-pinning gesture.
362+
await page.goto('about:blank')
363+
await page.goto('/#/quickpanel')
364+
await expect(page.getByRole('combobox', { name: 'Quick Panel search' })).toBeFocused()
365+
texts = await orderedLabels()
366+
frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
367+
pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
368+
expect(pinnedIndex).toBeLessThan(frequentIndex)
369+
await expect(page.getByRole('button', { name: `Unpin "${pinnedLabel}"` })).toBeVisible()
370+
371+
// Unpinning reverts to frecency order.
372+
await page.getByRole('button', { name: `Unpin "${pinnedLabel}"` }).click()
373+
await expect(page.getByRole('button', { name: `Pin "${pinnedLabel}"` })).toBeVisible()
374+
texts = await orderedLabels()
375+
frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
376+
pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
377+
expect(frequentIndex).toBeLessThan(pinnedIndex)
378+
379+
await deleteWorkflow(page, pinnedLabel)
380+
await deleteWorkflow(page, frequentLabel)
381+
})

frontend/src/app/CommandPalette.module.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,18 @@
2525
.list {
2626
max-height: min(60vh, 480px);
2727
}
28+
29+
/* Workflow pin toggle (docs/goals/BACKLOG.md Standing #5) -- same pair
30+
(including the `pointer-events: auto` override of Primer's own
31+
trailingVisual `VisualWrap` reset) as app/QuickPanel.module.css's
32+
identical classes; see that file's own comment for the full
33+
reasoning. */
34+
.pinToggle {
35+
color: var(--fgColor-muted);
36+
pointer-events: auto;
37+
}
38+
39+
.pinnedIndicator {
40+
color: var(--fgColor-accent);
41+
pointer-events: auto;
42+
}

frontend/src/app/CommandPalette.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useEffect, useMemo, useRef, useState } from 'react'
22
import type { ElementType, ReactNode } from 'react'
33
import { useTranslation } from 'react-i18next'
4-
import { Dialog, Text } from '@primer/react'
4+
import { Dialog, IconButton, Text } from '@primer/react'
55
import { FilteredActionList } from '@primer/react/experimental'
6-
import { CommandPaletteIcon, PencilIcon, PlayIcon, TabIcon, XIcon } from '@primer/octicons-react'
6+
import { CommandPaletteIcon, PencilIcon, PinIcon, PlayIcon, TabIcon, XIcon } from '@primer/octicons-react'
77
import { ExecutionService, RunKind } from '../shared/bindings'
88
import { COMMANDS } from '../shared/commands'
99
import { generateSamplePayload } from '../shared/configSchema'
@@ -14,7 +14,7 @@ import { findRootNode } from '../composition/triggerRowInfo'
1414
import { clearScratch } from '../composition/canvasScratch'
1515
import { filterPaletteEntries } from './paletteFilter'
1616
import type { PaletteSearchable } from './paletteFilter'
17-
import { sortWorkflowsByFrecency } from './workflowFrecency'
17+
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
1818
import { HotkeyHint } from './HotkeyHint'
1919
import styles from './CommandPalette.module.css'
2020

@@ -120,6 +120,12 @@ export function CommandPalette() {
120120
const activateWorkTab = useAppStore((s) => s.activateWorkTab)
121121
const closeWorkTab = useAppStore((s) => s.closeWorkTab)
122122
const pushActivity = useAppStore((s) => s.pushActivity)
123+
// Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5) -- same
124+
// store-owned ordered id list app/QuickPanel.tsx reads, shared across
125+
// both surfaces since they run in the same main-window JS context
126+
// (unlike the Quick Panel's separate Wails window).
127+
const pinnedWorkflowIds = useAppStore((s) => s.pinnedWorkflowIds)
128+
const togglePinnedWorkflow = useAppStore((s) => s.togglePinnedWorkflow)
123129
const [query, setQuery] = useState('')
124130
const [mostUsedRank, setMostUsedRank] = useState<Record<string, number>>({})
125131
const inputRef = useRef<HTMLInputElement>(null)
@@ -206,6 +212,7 @@ export function CommandPalette() {
206212
const workflowEntries = (wf: NonNullable<typeof workflows>[number]): PaletteEntry[] => {
207213
const root = findRootNode(wf.Nodes, wf.Edges)
208214
const kindLabel = root ? nodeTypes?.find((nt) => nt.ID === root.NodeTypeID)?.Label : undefined
215+
const pinned = pinnedWorkflowIds.includes(wf.ID)
209216
return [
210217
{
211218
id: `run:${wf.ID}`,
@@ -214,6 +221,24 @@ export function CommandPalette() {
214221
description: kindLabel ?? t('commandPalette.testRun'),
215222
searchText: `run ${wf.Label}`.toLowerCase(),
216223
leadingVisual: PlayIcon,
224+
// A subtle pin toggle (docs/goals/BACKLOG.md Standing #5),
225+
// same shape app/QuickPanel.tsx's own workflow row carries --
226+
// stopPropagation so the click doesn't also trigger the row's
227+
// own onAction (which would run the workflow AND close the
228+
// palette).
229+
trailingVisual: (
230+
<IconButton
231+
icon={PinIcon}
232+
aria-label={pinned ? t('commandPalette.unpinWorkflow', { label: wf.Label }) : t('commandPalette.pinWorkflow', { label: wf.Label })}
233+
size="small"
234+
variant="invisible"
235+
className={pinned ? styles.pinnedIndicator : styles.pinToggle}
236+
onClick={(e) => {
237+
e.stopPropagation()
238+
togglePinnedWorkflow(wf.ID)
239+
}}
240+
/>
241+
),
217242
run: () => runWorkflowTest(wf.ID, wf.Label),
218243
},
219244
{
@@ -266,16 +291,16 @@ export function CommandPalette() {
266291
const allEntries = useMemo<PaletteEntry[]>(() => {
267292
if (restState) {
268293
const navCommands = COMMANDS.filter((c) => isNavCommandId(c.id)).map(commandEntry)
269-
const topWorkflows = sortWorkflowsByFrecency(workflows ?? [], mostUsedRank).slice(0, REST_STATE_WORKFLOW_LIMIT)
294+
const topWorkflows = sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).slice(0, REST_STATE_WORKFLOW_LIMIT)
270295
return [...navCommands, ...topWorkflows.flatMap(workflowEntries), ...workTabs.flatMap(tabEntries)]
271296
}
272297
return [
273298
...COMMANDS.map(commandEntry),
274-
...(workflows ?? []).flatMap(workflowEntries),
299+
...sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).flatMap(workflowEntries),
275300
...workTabs.flatMap(tabEntries),
276301
]
277-
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/t, already listed
278-
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, t])
302+
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/pinnedWorkflowIds/togglePinnedWorkflow/t, already listed
303+
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, pinnedWorkflowIds, t])
279304

280305
const filtered = restState ? allEntries : filterPaletteEntries(allEntries, query)
281306

frontend/src/app/QuickPanel.module.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,26 @@
1919
color: var(--fgColor-muted);
2020
border-top: 1px solid var(--borderColor-default);
2121
}
22+
23+
/* Workflow pin toggle (docs/goals/BACKLOG.md Standing #5): subtle
24+
muted outline when unpinned, accent-colored "filled" indicator once
25+
pinned -- both states are the same IconButton (PinIcon), just
26+
recolored via currentColor, matching CommandPalette.module.css's
27+
identical pair below. `pointer-events: auto` re-enables clicking:
28+
Primer's own ActionList.Item wraps every trailingVisual in a
29+
`VisualWrap` span with `pointer-events: none` (its trailing visuals
30+
are decorative by convention, not interactive) -- confirmed directly
31+
against the installed @primer/react's compiled ActionList.module.css,
32+
not assumed. A `none` ancestor still lets a descendant re-enable
33+
itself via `auto`, which is exactly what turns this row's pin toggle
34+
back into a real click target without fighting the library's own
35+
pointer-events reset. */
36+
.pinToggle {
37+
color: var(--fgColor-muted);
38+
pointer-events: auto;
39+
}
40+
41+
.pinnedIndicator {
42+
color: var(--fgColor-accent);
43+
pointer-events: auto;
44+
}

0 commit comments

Comments
 (0)