diff --git a/docs/SPEC.md b/docs/SPEC.md index 17d58c88..d4cd6239 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2658,8 +2658,15 @@ findings) and the build rationale are in Configure tab (`OpenMainWindow('configure:')`, 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`; @@ -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:')` → `app/useMillNavigate.ts` → `ConfigureView`'s `initialTab`), and diff --git a/docs/goals/BACKLOG.md b/docs/goals/BACKLOG.md index 290cb740..2d415561 100644 --- a/docs/goals/BACKLOG.md +++ b/docs/goals/BACKLOG.md @@ -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. diff --git a/frontend/e2e/quick-panel.spec.ts b/frontend/e2e/quick-panel.spec.ts index bee5cf0b..fa11d666 100644 --- a/frontend/e2e/quick-panel.spec.ts +++ b/frontend/e2e/quick-panel.spec.ts @@ -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) +}) diff --git a/frontend/src/app/CommandPalette.module.css b/frontend/src/app/CommandPalette.module.css index abd31aa8..3176d972 100644 --- a/frontend/src/app/CommandPalette.module.css +++ b/frontend/src/app/CommandPalette.module.css @@ -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; +} diff --git a/frontend/src/app/CommandPalette.tsx b/frontend/src/app/CommandPalette.tsx index b2d8c057..b78108bb 100644 --- a/frontend/src/app/CommandPalette.tsx +++ b/frontend/src/app/CommandPalette.tsx @@ -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' @@ -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' @@ -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>({}) const inputRef = useRef(null) @@ -206,6 +212,7 @@ export function CommandPalette() { const workflowEntries = (wf: NonNullable[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}`, @@ -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: ( + { + e.stopPropagation() + togglePinnedWorkflow(wf.ID) + }} + /> + ), run: () => runWorkflowTest(wf.ID, wf.Label), }, { @@ -266,16 +291,16 @@ export function CommandPalette() { const allEntries = useMemo(() => { 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) diff --git a/frontend/src/app/QuickPanel.module.css b/frontend/src/app/QuickPanel.module.css index fc26830d..3aa838a1 100644 --- a/frontend/src/app/QuickPanel.module.css +++ b/frontend/src/app/QuickPanel.module.css @@ -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; +} diff --git a/frontend/src/app/QuickPanel.tsx b/frontend/src/app/QuickPanel.tsx index 70b6fe82..8af6b7d3 100644 --- a/frontend/src/app/QuickPanel.tsx +++ b/frontend/src/app/QuickPanel.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ElementType, ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Events } from '@wailsio/runtime' -import { CounterLabel, Text } from '@primer/react' +import { CounterLabel, IconButton, Text } from '@primer/react' import { FilteredActionList } from '@primer/react/experimental' -import { CopyIcon, GearIcon, HomeIcon, PlayIcon } from '@primer/octicons-react' +import { CopyIcon, GearIcon, HomeIcon, PinIcon, PlayIcon } from '@primer/octicons-react' import { CompositionService, ExecutionService, RunKind, SettingsService } from '../shared/bindings' import type { ClipboardApplyPreview } from '../shared/bindings' import { generateSamplePayload } from '../shared/configSchema' @@ -14,7 +14,7 @@ import { ENTITY_ICON } from '../shared/entityIcons' import { CAPABILITY_ICON } from './navIcon' import { filterPaletteEntries } from './paletteFilter' import type { PaletteSearchable } from './paletteFilter' -import { sortWorkflowsByFrecency } from './workflowFrecency' +import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency' import { HotkeyHint } from './HotkeyHint' import { QuickPanelClipboardApply } from './QuickPanelClipboardApply' import styles from './QuickPanel.module.css' @@ -85,6 +85,11 @@ export function QuickPanel() { const requests = useAppStore((s) => s.requests) const lists = useConfigureEntityStore((s) => s.lists) const mcpServers = useConfigureEntityStore((s) => s.mcpServers) + // Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5): a + // plain ordered workflow-ID list, store-owned/localStorage-tier -- + // see shared/store.ts's own declaration comment for the schema. + const pinnedWorkflowIds = useAppStore((s) => s.pinnedWorkflowIds) + const togglePinnedWorkflow = useAppStore((s) => s.togglePinnedWorkflow) const [query, setQuery] = useState('') const [status, setStatus] = useState(null) // Frecency ranking (goal 0015's remainder item 1): workflowID -> @@ -301,9 +306,11 @@ export function QuickPanel() { const allEntries = useMemo(() => { const entries: PanelEntry[] = [] - // Frecency-sorted (goal 0015's remainder item 1) -- frequency-only, - // see mostUsedRank's own declaration comment. - for (const wf of sortWorkflowsByFrecency(workflows ?? [], mostUsedRank)) { + // Pinned-then-frecency-sorted (docs/goals/BACKLOG.md Standing #5 + + // goal 0015's remainder item 1) -- frequency-only among the + // unpinned tail, see mostUsedRank's own declaration comment. + for (const wf of sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds)) { + const pinned = pinnedWorkflowIds.includes(wf.ID) entries.push({ id: `run:${wf.ID}`, groupId: 'workflows', @@ -311,6 +318,22 @@ export function QuickPanel() { description: t('quickPanel.entries.enterToRun'), searchText: wf.Label.toLowerCase(), leadingVisual: PlayIcon, + // A subtle pin toggle (stopPropagation so the click doesn't + // also fire the row's own onAction/run) -- pinned shows a + // filled/accent-colored indicator, unpinned a muted outline. + trailingVisual: ( + { + e.stopPropagation() + togglePinnedWorkflow(wf.ID) + }} + /> + ), run: () => runWorkflow(wf.ID, wf.Label), }) } @@ -410,8 +433,8 @@ export function QuickPanel() { run: applyFromClipboard, }) return entries - // eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow/jumpToConfigure/openMain/applyFromClipboard close over state already listed or are stable - }, [workflows, mostUsedRank, requests, lists, mcpServers, reviewPendingCount]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow/jumpToConfigure/openMain/applyFromClipboard/togglePinnedWorkflow close over state already listed or are stable + }, [workflows, mostUsedRank, pinnedWorkflowIds, requests, lists, mcpServers, reviewPendingCount]) const filtered = filterPaletteEntries(allEntries, query) diff --git a/frontend/src/app/workflowFrecency.test.ts b/frontend/src/app/workflowFrecency.test.ts index 2b3faba4..0a6b5022 100644 --- a/frontend/src/app/workflowFrecency.test.ts +++ b/frontend/src/app/workflowFrecency.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sortWorkflowsByFrecency } from './workflowFrecency' +import { sortWorkflowsByFrecency, sortWorkflowsByPinnedAndFrecency } from './workflowFrecency' import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models' // Minimal-but-valid Workflow fixture -- every field the interface @@ -53,3 +53,51 @@ describe('sortWorkflowsByFrecency', () => { expect(list.map((w) => w.ID)).toEqual(['wf-1', 'wf-2']) }) }) + +describe('sortWorkflowsByPinnedAndFrecency', () => { + it('sorts a pinned workflow above a higher-frecency unpinned one', () => { + const pinned = makeWorkflow('wf-pinned') + const frequentlyRun = makeWorkflow('wf-frequently-run') + const sorted = sortWorkflowsByPinnedAndFrecency( + [frequentlyRun, pinned], + { 'wf-frequently-run': 99 }, + ['wf-pinned'], + ) + expect(sorted.map((w) => w.ID)).toEqual(['wf-pinned', 'wf-frequently-run']) + }) + + it('orders multiple pinned workflows by pin order (pin recency), not frecency', () => { + const a = makeWorkflow('wf-a') + const b = makeWorkflow('wf-b') + // b was run more often, but a was pinned first -- pin order wins + // among pinned rows. + const sorted = sortWorkflowsByPinnedAndFrecency([b, a], { 'wf-b': 50, 'wf-a': 1 }, ['wf-a', 'wf-b']) + expect(sorted.map((w) => w.ID)).toEqual(['wf-a', 'wf-b']) + }) + + it('falls back to frecency ordering for the unpinned tail, stable for ties', () => { + const pinned = makeWorkflow('wf-pinned') + const high = makeWorkflow('wf-high') + const low = makeWorkflow('wf-low') + const zero1 = makeWorkflow('wf-zero-1') + const zero2 = makeWorkflow('wf-zero-2') + const sorted = sortWorkflowsByPinnedAndFrecency( + [zero1, low, zero2, high, pinned], + { high: 0, 'wf-high': 9, 'wf-low': 2 }, + ['wf-pinned'], + ) + expect(sorted.map((w) => w.ID)).toEqual(['wf-pinned', 'wf-high', 'wf-low', 'wf-zero-1', 'wf-zero-2']) + }) + + it('a pinned id with no matching workflow is silently dropped, not rendered as a gap', () => { + const a = makeWorkflow('wf-a') + const sorted = sortWorkflowsByPinnedAndFrecency([a], {}, ['wf-deleted', 'wf-a']) + expect(sorted.map((w) => w.ID)).toEqual(['wf-a']) + }) + + it('does not mutate the input array', () => { + const list = [makeWorkflow('wf-1'), makeWorkflow('wf-2')] + sortWorkflowsByPinnedAndFrecency(list, {}, ['wf-2']) + expect(list.map((w) => w.ID)).toEqual(['wf-1', 'wf-2']) + }) +}) diff --git a/frontend/src/app/workflowFrecency.ts b/frontend/src/app/workflowFrecency.ts index c49addb8..731f4beb 100644 --- a/frontend/src/app/workflowFrecency.ts +++ b/frontend/src/app/workflowFrecency.ts @@ -29,3 +29,26 @@ export function sortWorkflowsByFrecency(workflows: Workflow[], runCounts: Record // survives unchanged until real usage data exists to reorder it. return [...workflows].sort((a, b) => (runCounts[b.ID] ?? 0) - (runCounts[a.ID] ?? 0)) } + +// Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5, split +// from goal 0015's remainder, schema LOCKED at prioritization): pinned +// rows sort ABOVE every frecency-sorted unpinned row, in `pinnedIds`'s +// own order (pin recency -- see shared/store.ts's togglePinnedWorkflow). +// A plain partition-then-concat over the existing frecency sort rather +// than a new comparator -- reuses sortWorkflowsByFrecency for the +// unpinned tail instead of re-deriving frecency ordering here too. +export function sortWorkflowsByPinnedAndFrecency( + workflows: Workflow[], + runCounts: Record, + pinnedIds: string[], +): Workflow[] { + const pinnedSet = new Set(pinnedIds) + const pinned = pinnedIds + .map((id) => workflows.find((wf) => wf.ID === id)) + .filter((wf): wf is Workflow => wf !== undefined) + const unpinned = sortWorkflowsByFrecency( + workflows.filter((wf) => !pinnedSet.has(wf.ID)), + runCounts, + ) + return [...pinned, ...unpinned] +} diff --git a/frontend/src/locales/en/app.json b/frontend/src/locales/en/app.json index 8cd9d82b..58d6e191 100644 --- a/frontend/src/locales/en/app.json +++ b/frontend/src/locales/en/app.json @@ -32,7 +32,9 @@ "reviewPendingDescription": "{{count}} pending", "reviewNoPending": "No pending reviews", "applyFromClipboard": "Apply from clipboard…", - "applyFromClipboardDescription": "Create or update a workflow from a copied Mill export" + "applyFromClipboardDescription": "Create or update a workflow from a copied Mill export", + "pinWorkflow": "Pin \"{{label}}\"", + "unpinWorkflow": "Unpin \"{{label}}\"" }, "status": { "running": "Running \"{{label}}\"…", @@ -98,6 +100,8 @@ "openTab": "Open tab", "closeLabel": "Close: {{label}}", "closeTab": "Close tab", + "pinWorkflow": "Pin \"{{label}}\"", + "unpinWorkflow": "Unpin \"{{label}}\"", "title": "Command palette", "subtitle": "Run a command, jump to a workflow, or switch tabs", "searchPlaceholder": "Type a command, workflow, or tab…", diff --git a/frontend/src/shared/store.ts b/frontend/src/shared/store.ts index 2c7f7738..d1130ea9 100644 --- a/frontend/src/shared/store.ts +++ b/frontend/src/shared/store.ts @@ -242,6 +242,15 @@ interface AppState { openPalette: () => void closePalette: () => void togglePalette: () => void + // Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5, split + // from goal 0015's remainder): a plain ordered workflow-ID list, + // store-owned, localStorage-tier -- schema LOCKED at prioritization, + // no per-workflow field and no new Go surface. Newly-pinned ids + // append to the end (pin order = pin recency); sortWorkflowsBy + // PinnedAndFrecency (app/workflowFrecency.ts) renders pinned rows in + // this array's order, above every frecency-sorted unpinned row. + pinnedWorkflowIds: string[] + togglePinnedWorkflow: (id: string) => void } // Shared across App/ActivityView (SPEC.md §1.3): App.tsx still owns the @@ -431,6 +440,13 @@ export const useAppStore = create()( openPalette: () => set({ paletteOpen: true }), closePalette: () => set({ paletteOpen: false }), togglePalette: () => set((state) => ({ paletteOpen: !state.paletteOpen })), + pinnedWorkflowIds: [], + togglePinnedWorkflow: (id) => + set((state) => ({ + pinnedWorkflowIds: state.pinnedWorkflowIds.includes(id) + ? state.pinnedWorkflowIds.filter((pinned) => pinned !== id) + : [...state.pinnedWorkflowIds, id], + })), }), { name: 'mill-app-view', @@ -450,6 +466,14 @@ export const useAppStore = create()( // degrades to "no active tab" rather than persisting a key // with nothing to match it against. activeWorkTabKey: activeKeyIfPresent(state.workTabs.filter(isRestorable), state.activeWorkTabKey), + // Pins are plain workflow-ID strings, not entity snapshots -- no + // restore/prune step needed at merge time the way workTabs + // needs one; a pin for a since-deleted workflow just never + // matches anything in the live `workflows` list and silently + // renders nothing extra (the pin toggle itself always reflects + // the CURRENT workflow list, never this persisted array + // directly). + pinnedWorkflowIds: state.pinnedWorkflowIds, }), merge: (persisted, current) => { const p = (persisted ?? {}) as Partial