Skip to content

Commit 0d98485

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 9eb3802 commit 0d98485

9 files changed

Lines changed: 290 additions & 17 deletions

File tree

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+
}

frontend/src/app/QuickPanel.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
22
import type { ElementType, ReactNode } from 'react'
33
import { useTranslation } from 'react-i18next'
44
import { Events } from '@wailsio/runtime'
5-
import { CounterLabel, Text } from '@primer/react'
5+
import { CounterLabel, IconButton, Text } from '@primer/react'
66
import { FilteredActionList } from '@primer/react/experimental'
7-
import { CopyIcon, GearIcon, HomeIcon, PlayIcon } from '@primer/octicons-react'
7+
import { CopyIcon, GearIcon, HomeIcon, PinIcon, PlayIcon } from '@primer/octicons-react'
88
import { CompositionService, ExecutionService, RunKind, SettingsService } from '../shared/bindings'
99
import type { ClipboardApplyPreview } from '../shared/bindings'
1010
import { generateSamplePayload } from '../shared/configSchema'
@@ -14,7 +14,7 @@ import { ENTITY_ICON } from '../shared/entityIcons'
1414
import { CAPABILITY_ICON } from './navIcon'
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 { QuickPanelClipboardApply } from './QuickPanelClipboardApply'
2020
import styles from './QuickPanel.module.css'
@@ -85,6 +85,11 @@ export function QuickPanel() {
8585
const requests = useAppStore((s) => s.requests)
8686
const lists = useConfigureEntityStore((s) => s.lists)
8787
const mcpServers = useConfigureEntityStore((s) => s.mcpServers)
88+
// Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5): a
89+
// plain ordered workflow-ID list, store-owned/localStorage-tier --
90+
// see shared/store.ts's own declaration comment for the schema.
91+
const pinnedWorkflowIds = useAppStore((s) => s.pinnedWorkflowIds)
92+
const togglePinnedWorkflow = useAppStore((s) => s.togglePinnedWorkflow)
8893
const [query, setQuery] = useState('')
8994
const [status, setStatus] = useState<string | null>(null)
9095
// Frecency ranking (goal 0015's remainder item 1): workflowID ->
@@ -301,16 +306,34 @@ export function QuickPanel() {
301306

302307
const allEntries = useMemo<PanelEntry[]>(() => {
303308
const entries: PanelEntry[] = []
304-
// Frecency-sorted (goal 0015's remainder item 1) -- frequency-only,
305-
// see mostUsedRank's own declaration comment.
306-
for (const wf of sortWorkflowsByFrecency(workflows ?? [], mostUsedRank)) {
309+
// Pinned-then-frecency-sorted (docs/goals/BACKLOG.md Standing #5 +
310+
// goal 0015's remainder item 1) -- frequency-only among the
311+
// unpinned tail, see mostUsedRank's own declaration comment.
312+
for (const wf of sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds)) {
313+
const pinned = pinnedWorkflowIds.includes(wf.ID)
307314
entries.push({
308315
id: `run:${wf.ID}`,
309316
groupId: 'workflows',
310317
text: wf.Label,
311318
description: t('quickPanel.entries.enterToRun'),
312319
searchText: wf.Label.toLowerCase(),
313320
leadingVisual: PlayIcon,
321+
// A subtle pin toggle (stopPropagation so the click doesn't
322+
// also fire the row's own onAction/run) -- pinned shows a
323+
// filled/accent-colored indicator, unpinned a muted outline.
324+
trailingVisual: (
325+
<IconButton
326+
icon={PinIcon}
327+
aria-label={pinned ? t('quickPanel.entries.unpinWorkflow', { label: wf.Label }) : t('quickPanel.entries.pinWorkflow', { label: wf.Label })}
328+
size="small"
329+
variant="invisible"
330+
className={pinned ? styles.pinnedIndicator : styles.pinToggle}
331+
onClick={(e) => {
332+
e.stopPropagation()
333+
togglePinnedWorkflow(wf.ID)
334+
}}
335+
/>
336+
),
314337
run: () => runWorkflow(wf.ID, wf.Label),
315338
})
316339
}
@@ -410,8 +433,8 @@ export function QuickPanel() {
410433
run: applyFromClipboard,
411434
})
412435
return entries
413-
// eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow/jumpToConfigure/openMain/applyFromClipboard close over state already listed or are stable
414-
}, [workflows, mostUsedRank, requests, lists, mcpServers, reviewPendingCount])
436+
// eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow/jumpToConfigure/openMain/applyFromClipboard/togglePinnedWorkflow close over state already listed or are stable
437+
}, [workflows, mostUsedRank, pinnedWorkflowIds, requests, lists, mcpServers, reviewPendingCount])
415438

416439
const filtered = filterPaletteEntries(allEntries, query)
417440

frontend/src/app/workflowFrecency.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { sortWorkflowsByFrecency } from './workflowFrecency'
2+
import { sortWorkflowsByFrecency, sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
33
import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models'
44

55
// Minimal-but-valid Workflow fixture -- every field the interface
@@ -53,3 +53,51 @@ describe('sortWorkflowsByFrecency', () => {
5353
expect(list.map((w) => w.ID)).toEqual(['wf-1', 'wf-2'])
5454
})
5555
})
56+
57+
describe('sortWorkflowsByPinnedAndFrecency', () => {
58+
it('sorts a pinned workflow above a higher-frecency unpinned one', () => {
59+
const pinned = makeWorkflow('wf-pinned')
60+
const frequentlyRun = makeWorkflow('wf-frequently-run')
61+
const sorted = sortWorkflowsByPinnedAndFrecency(
62+
[frequentlyRun, pinned],
63+
{ 'wf-frequently-run': 99 },
64+
['wf-pinned'],
65+
)
66+
expect(sorted.map((w) => w.ID)).toEqual(['wf-pinned', 'wf-frequently-run'])
67+
})
68+
69+
it('orders multiple pinned workflows by pin order (pin recency), not frecency', () => {
70+
const a = makeWorkflow('wf-a')
71+
const b = makeWorkflow('wf-b')
72+
// b was run more often, but a was pinned first -- pin order wins
73+
// among pinned rows.
74+
const sorted = sortWorkflowsByPinnedAndFrecency([b, a], { 'wf-b': 50, 'wf-a': 1 }, ['wf-a', 'wf-b'])
75+
expect(sorted.map((w) => w.ID)).toEqual(['wf-a', 'wf-b'])
76+
})
77+
78+
it('falls back to frecency ordering for the unpinned tail, stable for ties', () => {
79+
const pinned = makeWorkflow('wf-pinned')
80+
const high = makeWorkflow('wf-high')
81+
const low = makeWorkflow('wf-low')
82+
const zero1 = makeWorkflow('wf-zero-1')
83+
const zero2 = makeWorkflow('wf-zero-2')
84+
const sorted = sortWorkflowsByPinnedAndFrecency(
85+
[zero1, low, zero2, high, pinned],
86+
{ high: 0, 'wf-high': 9, 'wf-low': 2 },
87+
['wf-pinned'],
88+
)
89+
expect(sorted.map((w) => w.ID)).toEqual(['wf-pinned', 'wf-high', 'wf-low', 'wf-zero-1', 'wf-zero-2'])
90+
})
91+
92+
it('a pinned id with no matching workflow is silently dropped, not rendered as a gap', () => {
93+
const a = makeWorkflow('wf-a')
94+
const sorted = sortWorkflowsByPinnedAndFrecency([a], {}, ['wf-deleted', 'wf-a'])
95+
expect(sorted.map((w) => w.ID)).toEqual(['wf-a'])
96+
})
97+
98+
it('does not mutate the input array', () => {
99+
const list = [makeWorkflow('wf-1'), makeWorkflow('wf-2')]
100+
sortWorkflowsByPinnedAndFrecency(list, {}, ['wf-2'])
101+
expect(list.map((w) => w.ID)).toEqual(['wf-1', 'wf-2'])
102+
})
103+
})

frontend/src/app/workflowFrecency.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,26 @@ export function sortWorkflowsByFrecency(workflows: Workflow[], runCounts: Record
2929
// survives unchanged until real usage data exists to reorder it.
3030
return [...workflows].sort((a, b) => (runCounts[b.ID] ?? 0) - (runCounts[a.ID] ?? 0))
3131
}
32+
33+
// Workflow pins/favorites (docs/goals/BACKLOG.md Standing #5, split
34+
// from goal 0015's remainder, schema LOCKED at prioritization): pinned
35+
// rows sort ABOVE every frecency-sorted unpinned row, in `pinnedIds`'s
36+
// own order (pin recency -- see shared/store.ts's togglePinnedWorkflow).
37+
// A plain partition-then-concat over the existing frecency sort rather
38+
// than a new comparator -- reuses sortWorkflowsByFrecency for the
39+
// unpinned tail instead of re-deriving frecency ordering here too.
40+
export function sortWorkflowsByPinnedAndFrecency(
41+
workflows: Workflow[],
42+
runCounts: Record<string, number>,
43+
pinnedIds: string[],
44+
): Workflow[] {
45+
const pinnedSet = new Set(pinnedIds)
46+
const pinned = pinnedIds
47+
.map((id) => workflows.find((wf) => wf.ID === id))
48+
.filter((wf): wf is Workflow => wf !== undefined)
49+
const unpinned = sortWorkflowsByFrecency(
50+
workflows.filter((wf) => !pinnedSet.has(wf.ID)),
51+
runCounts,
52+
)
53+
return [...pinned, ...unpinned]
54+
}

0 commit comments

Comments
 (0)