Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2658,8 +2658,15 @@ findings) and the build rationale are in
Configure tab (`OpenMainWindow('configure:<tab>')`, a new
`app/useMillNavigate.ts` hook parsing the target, `ConfigureView`'s
new `initialTab` prop) — goal 0015's own remaining Acceptance
halves, see that goal file for what's still open (pins, the ⌘K
palette's own inline-hotkey-per-row detail, a ⌘?/⌘/ alias).
halves, see that goal file for what's still open (the ⌘K palette's
own inline-hotkey-per-row detail, a ⌘?/⌘/ alias). **Update
(2026-08-13, BACKLOG.md Standing #5)**: pins/favorites landed —
`pinnedWorkflowIds: string[]` on `shared/store.ts`'s existing
persisted store (localStorage tier, same as `activeWorkTabKey`), a
Primer `PinIcon` toggle on both the Quick Panel's and this ⌘K
palette's workflow rows, `app/workflowFrecency.ts`'s
`sortWorkflowsByPinnedAndFrecency` sorting pinned rows (in pin order)
above the existing frecency-sorted tail in both surfaces.
- **Auto-update** — `app.Updater` (Wails3's own first-party,
zero-new-dependency `v3/pkg/updater`) is `Init`'d in `main.go` with a
GitHub Releases provider pointed at `alicoding/mill`;
Expand Down Expand Up @@ -2713,8 +2720,8 @@ findings) and the build rationale are in
halves — landed into the **Quick Panel** (`app/QuickPanel.tsx`,
ADR-0033), not this ⌘K palette: frequency-only workflow sort off
goal 0014's `HomeMetrics.mostUsed` (`app/workflowFrecency.ts`; pins
stayed out — no pin/favorite concept exists anywhere in Mill, a
BACKLOG.md tech-debt line rather than invented schema), connector/
landed later as their own BACKLOG.md tech-debt line — see the
Update above), connector/
List/MCP-Server rows that jump the main window to the right
Configure tab (`SettingsService.OpenMainWindow('configure:<tab>')`
→ `app/useMillNavigate.ts` → `ConfigureView`'s `initialTab`), and
Expand Down
2 changes: 1 addition & 1 deletion docs/goals/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ live-review material, interleaved during owner reviews, not a lane.**
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).
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.
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)).
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.
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.
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).
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.
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.
Expand Down
88 changes: 88 additions & 0 deletions frontend/e2e/quick-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,91 @@ test('a parked MCP write bumps the Quick Panel review badge live, no reload', as
}
await restoreMCPWriteDefaults(page)
})

// docs/goals/BACKLOG.md Standing #5 -- workflow pins/favorites.
// workflowFrecency.test.ts already covers the pure
// sortWorkflowsByPinnedAndFrecency function; this proves the live
// wiring end to end: pinning via the panel row's own toggle overrides
// frecency ranking, unpinning reverts to it, and the pin survives a
// reload (the localStorage-tier persistence the schema calls for).
test('pinning a workflow from the panel row sorts it above frecency, unpinning reverts, and the pin persists across reload', async ({ page }) => {
const pinnedLabel = 'ZzE2ePinTargetPinned'
const frequentLabel = 'ZzE2ePinTargetFrequent'
// frequentLabel created FIRST, pinnedLabel second -- so absent any
// pin, frequentLabel's own run count would already outrank
// pinnedLabel (same "real proof, not a coincidental pass" discipline
// as the frecency test above).
await createSimpleWorkflow(page, frequentLabel)
await createSimpleWorkflow(page, pinnedLabel)

await page.goto('about:blank')
await page.goto('/#/quickpanel')
const search = page.getByRole('combobox', { name: 'Quick Panel search' })
await expect(search).toBeFocused()

// Build up frequentLabel's frecency via the panel's own Enter-to-run
// path, same as the frecency test above.
for (let i = 0; i < 2; i++) {
await search.fill(frequentLabel)
await expect(page.getByRole('option', { name: frequentLabel })).toBeVisible()
await page.keyboard.press('Enter')
await expect(page.getByTestId('quick-panel-status')).toContainText(`Started "${frequentLabel}"`)
await search.fill('')
}

const orderedLabels = async () => {
await search.fill('ZzE2ePinTarget')
const texts = await page.getByRole('option').allTextContents()
return texts
}

// Before pinning: DBOS run history becomes queryable shortly after
// RunWorkflow returns, not necessarily synchronously -- retry the
// fresh-mount reload + order check rather than a fixed sleep, same
// as the frecency test.
await expect(async () => {
await page.goto('about:blank')
await page.goto('/#/quickpanel')
await expect(search).toBeFocused()
const texts = await orderedLabels()
const frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
const pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
expect(frequentIndex).toBeGreaterThanOrEqual(0)
expect(pinnedIndex).toBeGreaterThanOrEqual(0)
expect(frequentIndex).toBeLessThan(pinnedIndex)
}).toPass({ timeout: 15_000 })

// Pin the never-run workflow via its row's pin toggle -- it should
// now sort ABOVE the frequently-run one despite having zero runs.
await search.fill(pinnedLabel)
await expect(page.getByRole('option', { name: pinnedLabel })).toBeVisible()
await page.getByRole('button', { name: `Pin "${pinnedLabel}"` }).click()
await expect(page.getByRole('button', { name: `Unpin "${pinnedLabel}"` })).toBeVisible()

let texts = await orderedLabels()
let frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
let pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
expect(pinnedIndex).toBeLessThan(frequentIndex)

// Persists across reload: a fresh mount of the same window still
// shows the pin above frecency, with no re-pinning gesture.
await page.goto('about:blank')
await page.goto('/#/quickpanel')
await expect(page.getByRole('combobox', { name: 'Quick Panel search' })).toBeFocused()
texts = await orderedLabels()
frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
expect(pinnedIndex).toBeLessThan(frequentIndex)
await expect(page.getByRole('button', { name: `Unpin "${pinnedLabel}"` })).toBeVisible()

// Unpinning reverts to frecency order.
await page.getByRole('button', { name: `Unpin "${pinnedLabel}"` }).click()
await expect(page.getByRole('button', { name: `Pin "${pinnedLabel}"` })).toBeVisible()
texts = await orderedLabels()
frequentIndex = texts.findIndex((t) => t.includes(frequentLabel))
pinnedIndex = texts.findIndex((t) => t.includes(pinnedLabel))
expect(frequentIndex).toBeLessThan(pinnedIndex)

await deleteWorkflow(page, pinnedLabel)
await deleteWorkflow(page, frequentLabel)
})
15 changes: 15 additions & 0 deletions frontend/src/app/CommandPalette.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,18 @@
.list {
max-height: min(60vh, 480px);
}

/* Workflow pin toggle (docs/goals/BACKLOG.md Standing #5) -- same pair
(including the `pointer-events: auto` override of Primer's own
trailingVisual `VisualWrap` reset) as app/QuickPanel.module.css's
identical classes; see that file's own comment for the full
reasoning. */
.pinToggle {
color: var(--fgColor-muted);
pointer-events: auto;
}

.pinnedIndicator {
color: var(--fgColor-accent);
pointer-events: auto;
}
39 changes: 32 additions & 7 deletions frontend/src/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ElementType, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog, Text } from '@primer/react'
import { Dialog, IconButton, Text } from '@primer/react'
import { FilteredActionList } from '@primer/react/experimental'
import { CommandPaletteIcon, PencilIcon, PlayIcon, TabIcon, XIcon } from '@primer/octicons-react'
import { CommandPaletteIcon, PencilIcon, PinIcon, PlayIcon, TabIcon, XIcon } from '@primer/octicons-react'
import { ExecutionService, RunKind } from '../shared/bindings'
import { COMMANDS } from '../shared/commands'
import { generateSamplePayload } from '../shared/configSchema'
Expand All @@ -14,7 +14,7 @@ import { findRootNode } from '../composition/triggerRowInfo'
import { clearScratch } from '../composition/canvasScratch'
import { filterPaletteEntries } from './paletteFilter'
import type { PaletteSearchable } from './paletteFilter'
import { sortWorkflowsByFrecency } from './workflowFrecency'
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
import { HotkeyHint } from './HotkeyHint'
import styles from './CommandPalette.module.css'

Expand Down Expand Up @@ -120,6 +120,12 @@ export function CommandPalette() {
const activateWorkTab = useAppStore((s) => s.activateWorkTab)
const closeWorkTab = useAppStore((s) => s.closeWorkTab)
const pushActivity = useAppStore((s) => s.pushActivity)
// Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5) -- same
// store-owned ordered id list app/QuickPanel.tsx reads, shared across
// both surfaces since they run in the same main-window JS context
// (unlike the Quick Panel's separate Wails window).
const pinnedWorkflowIds = useAppStore((s) => s.pinnedWorkflowIds)
const togglePinnedWorkflow = useAppStore((s) => s.togglePinnedWorkflow)
const [query, setQuery] = useState('')
const [mostUsedRank, setMostUsedRank] = useState<Record<string, number>>({})
const inputRef = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -206,6 +212,7 @@ export function CommandPalette() {
const workflowEntries = (wf: NonNullable<typeof workflows>[number]): PaletteEntry[] => {
const root = findRootNode(wf.Nodes, wf.Edges)
const kindLabel = root ? nodeTypes?.find((nt) => nt.ID === root.NodeTypeID)?.Label : undefined
const pinned = pinnedWorkflowIds.includes(wf.ID)
return [
{
id: `run:${wf.ID}`,
Expand All @@ -214,6 +221,24 @@ export function CommandPalette() {
description: kindLabel ?? t('commandPalette.testRun'),
searchText: `run ${wf.Label}`.toLowerCase(),
leadingVisual: PlayIcon,
// A subtle pin toggle (docs/goals/BACKLOG.md Standing #5),
// same shape app/QuickPanel.tsx's own workflow row carries --
// stopPropagation so the click doesn't also trigger the row's
// own onAction (which would run the workflow AND close the
// palette).
trailingVisual: (
<IconButton
icon={PinIcon}
aria-label={pinned ? t('commandPalette.unpinWorkflow', { label: wf.Label }) : t('commandPalette.pinWorkflow', { label: wf.Label })}
size="small"
variant="invisible"
className={pinned ? styles.pinnedIndicator : styles.pinToggle}
onClick={(e) => {
e.stopPropagation()
togglePinnedWorkflow(wf.ID)
}}
/>
),
run: () => runWorkflowTest(wf.ID, wf.Label),
},
{
Expand Down Expand Up @@ -266,16 +291,16 @@ export function CommandPalette() {
const allEntries = useMemo<PaletteEntry[]>(() => {
if (restState) {
const navCommands = COMMANDS.filter((c) => isNavCommandId(c.id)).map(commandEntry)
const topWorkflows = sortWorkflowsByFrecency(workflows ?? [], mostUsedRank).slice(0, REST_STATE_WORKFLOW_LIMIT)
const topWorkflows = sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).slice(0, REST_STATE_WORKFLOW_LIMIT)
return [...navCommands, ...topWorkflows.flatMap(workflowEntries), ...workTabs.flatMap(tabEntries)]
}
return [
...COMMANDS.map(commandEntry),
...(workflows ?? []).flatMap(workflowEntries),
...sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).flatMap(workflowEntries),
...workTabs.flatMap(tabEntries),
]
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/t, already listed
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, t])
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/pinnedWorkflowIds/togglePinnedWorkflow/t, already listed
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, pinnedWorkflowIds, t])

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

Expand Down
23 changes: 23 additions & 0 deletions frontend/src/app/QuickPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,26 @@
color: var(--fgColor-muted);
border-top: 1px solid var(--borderColor-default);
}

/* Workflow pin toggle (docs/goals/BACKLOG.md Standing #5): subtle
muted outline when unpinned, accent-colored "filled" indicator once
pinned -- both states are the same IconButton (PinIcon), just
recolored via currentColor, matching CommandPalette.module.css's
identical pair below. `pointer-events: auto` re-enables clicking:
Primer's own ActionList.Item wraps every trailingVisual in a
`VisualWrap` span with `pointer-events: none` (its trailing visuals
are decorative by convention, not interactive) -- confirmed directly
against the installed @primer/react's compiled ActionList.module.css,
not assumed. A `none` ancestor still lets a descendant re-enable
itself via `auto`, which is exactly what turns this row's pin toggle
back into a real click target without fighting the library's own
pointer-events reset. */
.pinToggle {
color: var(--fgColor-muted);
pointer-events: auto;
}

.pinnedIndicator {
color: var(--fgColor-accent);
pointer-events: auto;
}
Loading
Loading