From e29f6b4d7aabb1cd6c700a4be5fcc725bd40d9f5 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Wed, 12 Aug 2026 08:12:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20goal=200015=20remainder=20=E2=80=94=20f?= =?UTF-8?q?recency,=20pending-review,=20Configure=20jump=20rows=20(Quick?= =?UTF-8?q?=20Panel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four DoR-met remaining items land into the Quick Panel (ADR-0033), not the ⌘K palette: - Frecency sort (frequency-only): QuickPanel's workflow list ranks by ExecutionService.HomeMetrics' mostUsed -- goal 0014's own value-mirror substrate, reused rather than reinvented (app/workflowFrecency.ts, unit-tested + proven live via e2e). Pins stayed out: no pin/favorite concept exists anywhere in Mill (grepped first) -- recorded as its own BACKLOG.md tech-debt line instead of inventing schema under this goal. - Pending-review count: the Quick Panel is its own Wails window and can't share App.tsx's reviewPendingCount effect -- a second, independent read of ListRuns/PendingMCPWrites + the same two live events, display-only (doesn't duplicate the main window's SetPendingBadge/NotifyPendingApproval side effects). Proven e2e with a real parked MCP write, asserting the badge updates live with no reload. - Configure entities: connectors/Lists/MCP Servers now render as searchable/jumpable rows, landing the main window on the right Configure tab via a new 'configure:' mill-navigate target (app/useMillNavigate.ts, extracted out of App.tsx to stay under the 500-line convention; ConfigureView gained an initialTab prop). Out of scope, recorded honestly rather than silently dropped: a ⌘?/⌘/ multi-binding keybinding alias (needs the 1:1 command↔binding registry to become multi-binding) and pins/favorites (needs its own schema decision) both get BACKLOG.md tech-debt lines; the ⌘K palette's own inline-hotkey-per-row detail stays a named, untouched gap. Goal 0015's file stays open (not archived) until those land. Also fixes a real, CI-blocking bug found live this session: formatUpdated (shared/inventorySort.ts) silently ignored the `now` override formatLastChecked (shared/staleness.ts) already threaded through it, using the real wall clock instead -- staleness.test.ts's hardcoded NOW anchor caught this for real once the actual clock crossed a day boundary past it mid-session, flipping a "9 minutes ago" expectation to "yesterday". now is threaded through properly now. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh --- docs/SPEC.md | 35 ++++- docs/goals/0015-summon-quick-invoke.md | 74 +++++++++ docs/goals/BACKLOG.md | 4 +- frontend/e2e/quick-panel.spec.ts | 160 +++++++++++++++++++ frontend/src/app/App.tsx | 20 +-- frontend/src/app/QuickPanel.tsx | 183 +++++++++++++++++++++- frontend/src/app/useMillNavigate.ts | 29 ++++ frontend/src/app/workflowFrecency.test.ts | 54 +++++++ frontend/src/app/workflowFrecency.ts | 31 ++++ frontend/src/configure/ConfigureView.tsx | 11 +- frontend/src/shared/inventorySort.ts | 18 ++- frontend/src/shared/staleness.ts | 2 +- frontend/src/shared/store.ts | 8 +- 13 files changed, 594 insertions(+), 35 deletions(-) create mode 100644 frontend/src/app/useMillNavigate.ts create mode 100644 frontend/src/app/workflowFrecency.test.ts create mode 100644 frontend/src/app/workflowFrecency.ts diff --git a/docs/SPEC.md b/docs/SPEC.md index 878b08b5..36e98d8e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2491,7 +2491,19 @@ findings) and the build rationale are in main window also isn't visible, the accepted mitigation. Goal 0015's own ⌘K in-window palette is unchanged — still the surface for *already being in Mill*, distinct from the Quick Panel's *not - yet being in Mill* summon target. + yet being in Mill* summon target. **Update (2026-08-12)**: the + Quick Panel's workflow list is frecency-sorted (frequency-only, + `app/workflowFrecency.ts`, off goal 0014's `HomeMetrics.mostUsed`), + a "Review" row surfaces the panel's own independent pending-review + count (its own window, its own `ListRuns`/`PendingMCPWrites` read + + `guardrail-pending-changed`/`mcp-write-approval` subscription — it + can't share App.tsx's, a separate React tree entirely), and + connector/List/MCP-Server rows jump the main window to the right + 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). - **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`; @@ -2540,10 +2552,23 @@ findings) and the build rationale are in WITH its effective shortcut inline — the owner's unblock-yourself-in-place requirement — plus workflows (Enter = the same test-run semantics as the list Run button) and open work tabs - (jump/close). Frecency/pins, Configure entities, the pending-review - count, and a ⌘?/⌘/ alias — blocked on the 1:1 command↔binding - registry shape — stay recorded in the goal file, not silently - dropped). `workflow.run`'s default moved + (jump/close). **Update (2026-08-12)**: frecency, Configure entities, + and the pending-review count — goal 0015's own remaining Acceptance + 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/ + List/MCP-Server rows that jump the main window to the right + Configure tab (`SettingsService.OpenMainWindow('configure:')` + → `app/useMillNavigate.ts` → `ConfigureView`'s `initialTab`), and + the panel's own independent pending-review read+subscribe (separate + Wails window, can't share App.tsx's). This ⌘K palette's own + still-open gap — the *inline per-workflow hotkey/armed-state + detail* the Acceptance sentence's "showed them the hotkey" half + names — and a ⌘?/⌘/ alias (blocked on the 1:1 command↔binding + registry shape) both stay recorded in the goal file, not silently + dropped. `workflow.run`'s default moved off an initial ⌘R pick to ⌘↩ by owner decision, once implementation surfaced a real collision the original research missed: macOS's `DefaultApplicationMenu()` installs View > Reload on Cmd+R diff --git a/docs/goals/0015-summon-quick-invoke.md b/docs/goals/0015-summon-quick-invoke.md index a83d42f7..36325a8a 100644 --- a/docs/goals/0015-summon-quick-invoke.md +++ b/docs/goals/0015-summon-quick-invoke.md @@ -63,3 +63,77 @@ anything. runs a workflow" run path is real and built there today; the inline-hotkey-hint and frecency/pins halves of this acceptance bar are still the unbuilt remainder this goal file tracks, for either surface. + +## Remainder delivered 2026-08-12 — three of four items, into the Quick Panel + +Investigated and confirmed DoR-met (the usage substrate, the RPCs, and +the live-sync infra all already existed — nothing here needed new +backend surface): + +- **Frecency (frequency-only, not frequency+recency)**: the Quick + Panel's workflow list now sorts by `ExecutionService.HomeMetrics`' + `mostUsed` (goal 0014's own value-mirror substrate, `mostUsedFor` — + every run counted regardless of Kind/Status, over the entire local + run history, not a rolling window) — `app/workflowFrecency.ts` + (`sortWorkflowsByFrecency`), unit-tested + (`workflowFrecency.test.ts`) and proven live end to end (a workflow + run twice from the panel sorts above one never run, + `e2e/quick-panel.spec.ts`). **Pins are NOT built** — grepped the + whole codebase first, confirmed no pin/favorite concept exists + anywhere (not even a stub) — recorded as its own, smaller tech-debt + line in `docs/goals/BACKLOG.md` rather than inventing schema for it + ad hoc under this goal. +- **Pending-review count**: the Quick Panel is its own Wails window + (ADR-0033) — App.tsx's existing `reviewPendingCount` effect only + ever ran in the main window's React tree. `QuickPanel.tsx` now owns + a second, independent read of the same two sources + (`ExecutionService.ListRuns` pending runs + `SettingsService. + PendingMCPWrites`) and the same two live-update events + (`guardrail-pending-changed`, `mcp-write-approval`) — a "Review" row + always present (unblock-yourself-in-place), badged once non-zero. + Deliberately does NOT re-run App.tsx's `SetPendingBadge`/ + `NotifyPendingApproval` side effects (the main window already owns + those; a second window firing them too would double-notify). + Proven e2e with a REAL parked MCP write via the existing MCP test + client (`mcpTestClient.ts`), asserting the badge updates live while + the panel stays open and mounted — not just on next open. +- **Configure entities**: connectors (`ConfigureService.HTTPRequests`, + "Integration" tab), Lists (`.Lists`), MCP Servers (`.MCPServers`) — + all already-bound RPCs, already read via the shared stores + (`shared/store.ts`'s `requests`, `shared/configureEntityStore.ts`'s + `lists`/`mcpServers`) — now render as searchable/jumpable rows + alongside workflows, each landing the MAIN window on its own + Configure tab (`SettingsService.OpenMainWindow('configure:')` + → a new `app/useMillNavigate.ts` hook, extracted out of App.tsx to + stay under the 500-line convention → `View.tab` → `ConfigureView`'s + new `initialTab` prop). Lands on the TAB, not the individual + entity's own row within it — deep-linking to one specific entity's + edit form would need `ConfigureView`'s tab components to accept a + selected-row id too, real additional scope beyond what this + remainder's DoR covered. Proven e2e (search "country" → jump → + the main window's Configure > Lists tab is visible with the seeded + "Example: Country codes" row). + +## Still open — not touched this session, named so they're not silently dropped + +- **Inline hotkey hint per workflow row** (the ⌘K palette's own + documented simplification from the 2026-08-11 core build — + `app/CommandPalette.tsx`'s workflow-row comment: shows the trigger + NodeType label, e.g. "Hotkey trigger," not the live armed/hotkey- + combo detail `TriggerRowLabel.tsx` owns). Not part of this session's + investigated-DoR-met scope; still the literal "showed them the + hotkey they'll use instead next time" half of this goal's Acceptance + sentence. +- **⌘?/⌘/ multi-binding alias** — needs a command to carry more than + one `KeyCombo` (today's registry is 1:1, `shared/commands.ts`'s + `defaultBinding: KeyCombo | null`); recorded as a BACKLOG.md + tech-debt line, not silently dropped. +- **Pins/favorites** — no schema exists yet for either surface; + recorded as its own, smaller BACKLOG.md tech-debt line (separate + from the alias — different kind of missing infra, a data model vs a + registry shape). + +This goal file stays OPEN (not archived) until the three items above +are picked up — none of them block the palette/panel being genuinely +useful today, but the Acceptance sentence isn't fully true until the +first one lands. diff --git a/docs/goals/BACKLOG.md b/docs/goals/BACKLOG.md index f02347e7..12a751fe 100644 --- a/docs/goals/BACKLOG.md +++ b/docs/goals/BACKLOG.md @@ -129,13 +129,15 @@ live-review material, interleaved during owner reviews, not a lane.** 8. [x] [0013 — Canonical type system](archive/0013-canonical-type-system.md) — COMPLETE 2026-08-10 (typedfield leaf pkg; all 4 vocabularies converged incl. openapispec Phase 3; the #1 kernel investment) 9. [x] [0011 — Lists maturation](archive/0011-lists-maturation.md) — DELIVERED 2026-08-12 (harvested from a parallel owner session + reconciled onto main: typed Columns/Rows against ADR-0029's canonical typedfield, system-managed audit columns w/ Expired-excluded-by-default, `list-search` node w/ go-edlib fuzzy matching, in-place legacy-List migration; CSV import + full per-run dataset snapshot named-deferred) 10. [x] [0014 — Home dashboard / value mirror](archive/0014-home-dashboard.md) — delivered 2026-08-10 (Recharts, industry-decided metric semantics, editable minutes-saved, default landing) -11. [ ] [0015 — Summon quick-invoke](0015-summon-quick-invoke.md) — CORE delivered 2026-08-11 (⌘K palette: commands with inline shortcuts, workflow run, tab jump/close; delegated build); PHASE 2 delivered same day (ADR-0033: the summon hotkey opens a dedicated floating Quick Panel — frameless, floats over fullscreen, Esc/blur dismiss, focus-yield; supersedes "summon opens the main window"). Remainder open: frecency/pins (needs the 0014 usage substrate), Configure entities, pending-review count, ⌘?/⌘/ alias (needs multi-binding registry support) +11. [ ] [0015 — Summon quick-invoke](0015-summon-quick-invoke.md) — CORE delivered 2026-08-11 (⌘K palette: commands with inline shortcuts, workflow run, tab jump/close; delegated build); PHASE 2 delivered same day (ADR-0033: the summon hotkey opens a dedicated floating Quick Panel — frameless, floats over fullscreen, Esc/blur dismiss, focus-yield; supersedes "summon opens the main window"). REMAINDER delivered 2026-08-12, into the Quick Panel: frecency sort (frequency-only, `app/workflowFrecency.ts` off goal 0014's `HomeMetrics.mostUsed`), pending-review count (own window-local read+subscribe, `QuickPanel.tsx`), Configure entities as jumpable rows (`configure:` via new `app/useMillNavigate.ts` + `ConfigureView.initialTab`). Still open, named in the goal file, not silently dropped: the ⌘K palette's own inline-hotkey-per-workflow-row detail (a pre-existing documented simplification, untouched this session); pins/favorites and the ⌘?/⌘/ multi-binding alias — see their own tech-debt lines below (Standing section). 12. [x] [0022 — Workflow view mode](archive/0022-workflow-view-mode.md) — delivered 2026-08-11 (row click → read-only canvas w/ Run+step-debug; Edit explicit in-place mode switch; breakpoint dot moved onto the node card, both modes; fixed a latent bug where a policy deny could hide a breakpoint's existence) 13. [x] [0020 — Workflow breakpoints](archive/0020-workflow-breakpoints.md) — delivered 2026-08-11 (ADR-0031 full scope incl. step mode + MCP debug tools; delegated build; found+fixed the ExecuteOptions.WorkflowID never-set bug that silently disabled all workflow/instance-scoped guardrail rules at runtime) **Standing** - [ ] [0001 — Authoring-surface overhaul](0001-authoring-surface-overhaul.md) (spacing audit + §3.8 prototype elements — live-review material) - [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) (owner-mandated 2026-08-11: orchestrator live-probes the MCP surface against the bank use cases, logs ranked gaps, fixes graduate out; phase 1 done — 4 gaps + 1 confirmed-by-design) +- [ ] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — no pin/favorite concept exists anywhere in Mill today (grepped before scoping it out); needs its own small schema decision (which store owns a pin list, per-workflow or a plain ID set) before any build — deliberately not invented ad hoc under 0015's frecency-only ship. Quick Panel's workflow list sorts by frequency alone until this lands. +- [ ] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — the owner's goal-0015 "bind ⌘? (and/or ⌘/) to open the palette too" ask needs a command to carry more than one `KeyCombo`; today's registry (`shared/commands.ts`) is 1:1 (`defaultBinding: KeyCombo | null`). Needs a real schema call (array vs. a small alias table) before it's buildable — real data-model infrastructure, not a self-contained UI change. **Delivered** - [x] [0003 — MCP authoring live dogfood](archive/0003-mcp-authoring-dogfood.md) — 2026-08-10 diff --git a/frontend/e2e/quick-panel.spec.ts b/frontend/e2e/quick-panel.spec.ts index 89d13ca2..bee5cf0b 100644 --- a/frontend/e2e/quick-panel.spec.ts +++ b/frontend/e2e/quick-panel.spec.ts @@ -1,5 +1,10 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { test, expect } from './fixtures/server' import { clickRowAction } from './inventoryRow' +import { + connectMCPClient, enableMCPWritesWithApprovalRequired, exportWorkflowViaMCP, + findWorkflowIdByLabel, restoreMCPWriteDefaults, +} from './mcpTestClient' // Exercises the Quick Panel's frontend (docs/adr/0033-quick-panel- // second-window.md, app/QuickPanel.tsx) at its hash route @@ -131,3 +136,158 @@ test('a seeded workflow is listed and Enter runs it, showing a started confirmat await deleteWorkflow(page, label) }) + +// docs/goals/0015-summon-quick-invoke.md's remainder, item 3: Configure +// entities as searchable/jumpable rows. Reuses the seeded "Example: +// Country codes" List (internal/domain/list/builtin.go) rather than +// creating a throwaway one, same reasoning configure-lists.spec.ts's +// own header comment gives -- nothing List-shaped to clean up. +test('typing a few chars and picking a Configure entity jumps the main window to its tab', async ({ page }) => { + // Two real pages against the SAME worker server -- the main window + // and the Quick Panel are genuinely separate Wails windows in + // production (ADR-0033); realtime-cross-surface.spec.ts's own "two + // open surfaces" pattern is the real cross-window shape to test + // against, not one page simulating both. The Quick Panel's jump row + // asks the MAIN window to navigate (SettingsService.OpenMainWindow, + // app/useMillNavigate.ts) -- it never renders Configure itself. + const mainPage = await page.context().newPage() + try { + await mainPage.goto('/') + + await page.goto('/#/quickpanel') + const search = page.getByRole('combobox', { name: 'Quick Panel search' }) + await expect(search).toBeFocused() + await search.fill('country') + + const option = page.getByRole('option', { name: 'Example: Country codes' }) + await expect(option).toBeVisible() + await option.click() + + // ConfigureView's Lists tab panel becomes visible on the MAIN + // window -- every TabPanel stays mounted (ConfigureView.tsx's own + // doc comment), so `toBeVisible()` (not just present in the DOM) is + // the real "did the jump land on the right tab" signal. + await expect(mainPage.getByTestId('configure-lists')).toBeVisible({ timeout: 10_000 }) + await expect( + mainPage.locator('[data-testid="inventory-row"][data-entity="list"]', { + has: mainPage.getByText('Example: Country codes', { exact: true }), + }), + ).toBeVisible() + } finally { + await mainPage.close() + } +}) + +// docs/goals/0015-summon-quick-invoke.md's remainder, item 1: frecency +// sort. workflowFrecency.test.ts already covers the pure ranking +// function in isolation; this proves the live wiring end to end +// (QuickPanel actually calls ExecutionService.HomeMetrics and reads its +// response's real field names -- a casing/wiring mistake here would +// pass the pure unit test but fail this one). +test('a workflow run from the panel a few times sorts above one that was never run', async ({ page }) => { + const frequentLabel = 'ZzE2eFrecencyFrequent' + const neverRunLabel = 'ZzE2eFrecencyNeverRun' + // neverRunLabel created FIRST, frequentLabel second -- so the + // fetch's own default (creation-order-ish) fallback would already + // put neverRunLabel ahead absent any frecency reordering. Only the + // frecency sort itself can flip that, making this a real proof + // rather than a coincidental pass. + await createSimpleWorkflow(page, neverRunLabel) + await createSimpleWorkflow(page, frequentLabel) + + await page.goto('about:blank') + await page.goto('/#/quickpanel') + const search = page.getByRole('combobox', { name: 'Quick Panel search' }) + await expect(search).toBeFocused() + + // Run the "frequent" workflow twice via the panel's own Enter-to-run + // path (ExecutionService.RunWorkflow, RunKindTest) -- the exact usage + // HomeMetrics' MostUsed counts (executionservice_home.go's + // mostUsedFor: every run regardless of Kind/Status). + 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('') + } + + // DBOS run history becomes queryable shortly after RunWorkflow + // returns, not necessarily synchronously with it -- retry the + // fresh-mount reload + order check rather than a fixed sleep. + await expect(async () => { + await page.goto('about:blank') + await page.goto('/#/quickpanel') + await expect(page.getByRole('combobox', { name: 'Quick Panel search' })).toBeFocused() + await page.getByRole('combobox', { name: 'Quick Panel search' }).fill('ZzE2eFrecency') + const optionTexts = await page.getByRole('option').allTextContents() + const frequentIndex = optionTexts.findIndex((t) => t.includes(frequentLabel)) + const neverRunIndex = optionTexts.findIndex((t) => t.includes(neverRunLabel)) + expect(frequentIndex).toBeGreaterThanOrEqual(0) + expect(neverRunIndex).toBeGreaterThanOrEqual(0) + expect(frequentIndex).toBeLessThan(neverRunIndex) + }).toPass({ timeout: 15_000 }) + + await deleteWorkflow(page, frequentLabel) + await deleteWorkflow(page, neverRunLabel) +}) + +// docs/goals/0015-summon-quick-invoke.md's remainder, item 2: +// pending-review count. Judged e2e-feasible (not manual-only, per +// .claude/rules/testing.md's own decision criterion) -- the same real +// MCP write client + park-and-poll lifecycle mcp-write-approval.spec.ts +// already drives headlessly. This proves the LIVE subscription (the +// panel stays open, on the same page, across the park -- no reload +// between parking the write and reading the badge), not just the +// on-mount fetch every other assertion here already exercises +// incidentally. +test('a parked MCP write bumps the Quick Panel review badge live, no reload', async ({ page }, testInfo) => { + await enableMCPWritesWithApprovalRequired(page) + + const sourceLabel = 'ZzE2eQuickPanelReviewSource' + await createSimpleWorkflow(page, sourceLabel) + + await page.goto('about:blank') + await page.goto('/#/quickpanel') + const search = page.getByRole('combobox', { name: 'Quick Panel search' }) + await expect(search).toBeFocused() + + const client = await connectMCPClient(testInfo.parallelIndex) + let importResultPromise: ReturnType + try { + const sourceId = await findWorkflowIdByLabel(client, sourceLabel) + const exported = await exportWorkflowViaMCP(client, sourceId) + importResultPromise = client.callTool({ name: 'import_workflow', arguments: { json: exported } }) + + const badge = page.getByTestId('quick-panel-review-count') + await expect(badge).toHaveText('1', { timeout: 15_000 }) + + // Resolve it via the main window's real Review queue -- same + // approve path mcp-write-approval.spec.ts exercises end to end -- + // so the park doesn't leak into another test's own pending count. + await page.goto('/') + await page.getByRole('link', { name: 'Review' }).click() + const item = page.getByTestId('review-mcp-write-item').first() + await expect(item).toBeVisible({ timeout: 15_000 }) + await item.getByTestId('review-mcp-write-approve').click() + await expect(page.getByTestId('review-mcp-write-item')).toHaveCount(0, { timeout: 10_000 }) + + const result = await importResultPromise + if (result.isError) { + throw new Error(`import_workflow ultimately errored after approval: ${JSON.stringify(result.content)}`) + } + } finally { + await client.close() + } + + // Cleanup: both minted workflows (the source + the one approving the + // import minted) and the settings toggle. + await page.getByRole('link', { name: 'Workflows' }).click() + let remaining = await workflowRow(page, sourceLabel).count() + while (remaining > 0) { + await clickRowAction(page, workflowRow(page, sourceLabel).first(), 'Delete') + remaining -= 1 + await expect(workflowRow(page, sourceLabel)).toHaveCount(remaining) + } + await restoreMCPWriteDefaults(page) +}) diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 49686bf0..a01f889f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -20,6 +20,7 @@ import { CommandPalette } from "./CommandPalette"; import { BuildIdentityBadge } from "./BuildIdentityBadge"; import { COLOR_MODE_STORAGE_KEY, SIDEBAR_OPEN_STORAGE_KEY } from "./theme"; import { pageIconFor, pageLabelFor } from './pageMeta' +import { useMillNavigate } from './useMillNavigate' import styles from "./App.module.css"; // Show the actual Wails version this project was generated against. @@ -252,22 +253,7 @@ function App() { }) }, []) - // docs/adr/0033: the Quick Panel's "Open Settings" row (a separate - // Wails window, own React tree -- it can't call setView directly) - // asks the main window to navigate via SettingsService.OpenMainWindow, - // which shows/focuses this window and emits this event. Empty-string - // view (the panel's "Open Mill" row) means "just show the window," - // no navigation. 'review' is the floating approval prompt's own - // "Open in Mill" row for a guardrail/human-review park - // (docs/goals/0023 item 1, app/ApprovalPrompt.tsx) -- same mechanism, - // one more target. - useEffect(() => { - return Events.On('mill-navigate', (evt) => { - const target = evt.data as string; - if (target === 'settings') setView({ kind: 'settings' }); - if (target === 'review') setView({ kind: 'review' }); - }); - }, [setView]); + useMillNavigate(setView); useEffect(() => { return Events.On('hotkey-activity', (evt) => { @@ -452,7 +438,7 @@ function App() { {view.kind === 'composition' && } - {view.kind === 'configure' && } + {view.kind === 'configure' && } {view.kind === 'settings' && } diff --git a/frontend/src/app/QuickPanel.tsx b/frontend/src/app/QuickPanel.tsx index 4095503d..1d3ec65b 100644 --- a/frontend/src/app/QuickPanel.tsx +++ b/frontend/src/app/QuickPanel.tsx @@ -1,13 +1,18 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ElementType, ReactNode } from 'react' -import { Text } from '@primer/react' +import { Events } from '@wailsio/runtime' +import { CounterLabel, Text } from '@primer/react' import { FilteredActionList } from '@primer/react/experimental' import { GearIcon, HomeIcon, PlayIcon } from '@primer/octicons-react' import { ExecutionService, RunKind, SettingsService } from '../shared/bindings' import { generateSamplePayload } from '../shared/configSchema' -import { useAppStore, refreshWorkflows } from '../shared/store' +import { useAppStore, refreshWorkflows, refreshRequests } from '../shared/store' +import { useConfigureEntityStore, refreshLists, refreshMCPServers } from '../shared/configureEntityStore' +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 styles from './QuickPanel.module.css' // docs/adr/0033-quick-panel-second-window.md: the search+run surface @@ -31,7 +36,7 @@ import styles from './QuickPanel.module.css' // this component never calls window.close() or touches window // visibility itself. -type PanelGroupId = 'workflows' | 'actions' +type PanelGroupId = 'workflows' | 'configure' | 'actions' interface PanelEntry extends PaletteSearchable { id: string @@ -45,17 +50,60 @@ interface PanelEntry extends PaletteSearchable { const GROUP_METADATA = [ { groupId: 'workflows' as const, header: { title: 'Workflows' } }, + { groupId: 'configure' as const, header: { title: 'Configure' } }, { groupId: 'actions' as const, header: { title: 'Mill' } }, ] +// Frequency-only ranking window (goal 0015's remainder): the entire +// local run history, not a rolling recent window -- "frequently-used +// float up" shouldn't reset just because a workflow's last run was +// over a month ago. ExecutionService.HomeMetrics requires an RFC3339 +// instant, not an "all time" flag, so this is simply an instant well +// before Mill could have any real run history. +const FRECENCY_FROM_ISO = new Date(0).toISOString() + function ShortcutHint({ text }: { text: string }) { return {text} } export function QuickPanel() { const workflows = useAppStore((s) => s.workflows) + // Configure's three reusable, already-Wails-bound entity kinds + // (goal 0015's remainder item 3) -- connectors ("Integration" in the + // UI, still the HTTPRequest/`request` entity under the hood, ADR-0016), + // Lists, MCP Servers. Read off the SAME shared stores App.tsx's own + // mill-data-changed router feeds (store.ts's `requests`, + // configureEntityStore.ts's `lists`/`mcpServers`) rather than a new + // QuickPanel-local fetch shape -- this window still refetches them + // itself below (goal 0017's per-window fetch pattern: store state + // isn't shared across separate Wails windows/JS contexts, only the + // fetch FUNCTIONS are reused). + const requests = useAppStore((s) => s.requests) + const lists = useConfigureEntityStore((s) => s.lists) + const mcpServers = useConfigureEntityStore((s) => s.mcpServers) const [query, setQuery] = useState('') const [status, setStatus] = useState(null) + // Frecency ranking (goal 0015's remainder item 1): workflowID -> + // RunCount, straight off ExecutionService.HomeMetrics' MostUsed -- + // the same usage substrate goal 0014's Home value mirror already + // computes (executionservice_home.go's mostUsedFor), not a new + // algorithm. Pins are explicitly OUT of scope (docs/goals/BACKLOG.md + // tech-debt line) -- no pin/favorite concept exists anywhere in Mill + // yet, so this is frequency-only, matching what the substrate + // actually provides today. + const [mostUsedRank, setMostUsedRank] = useState>({}) + // Pending-review count (goal 0015's remainder item 2): the Quick + // Panel is its own Wails window (ADR-0033) -- App.tsx's own + // reviewPendingCount effect only runs in the main window's React + // tree, so this is a second, independent instance of the exact same + // read (ExecutionService.ListRuns' pending runs + SettingsService. + // PendingMCPWrites) and the exact same live-update events + // (guardrail-pending-changed, mcp-write-approval) -- display-only + // here, deliberately NOT re-running App.tsx's SetPendingBadge/ + // NotifyPendingApproval side effects (the main window already owns + // those; duplicating them per-window would double-fire OS + // notifications for the same pending item). + const [reviewPendingCount, setReviewPendingCount] = useState(0) const inputRef = useRef(null) // Every show of this window is a fresh session, not a continuation -- @@ -69,6 +117,10 @@ export function QuickPanel() { useEffect(() => { const focusAndReset = () => { void refreshWorkflows() + void refreshRequests() + void refreshLists() + void refreshMCPServers() + void refreshFrecency() setQuery('') setStatus(null) // One frame so a just-unhidden webview has actually finished @@ -89,6 +141,54 @@ export function QuickPanel() { } }, []) + const refreshFrecency = () => { + ExecutionService.HomeMetrics(FRECENCY_FROM_ISO, new Date().toISOString(), true) + .then((metrics) => { + const rank: Record = {} + for (const usage of metrics.mostUsed ?? []) rank[usage.workflowID] = usage.runCount + setMostUsedRank(rank) + }) + .catch(() => {}) + } + + // Live sync while the panel stays open (goal 0017's mill-data-changed + // infra, docs/adr/0025): a Configure entity created/renamed/deleted + // elsewhere -- the main window, another tool, an MCP author -- while + // this window happens to be open updates the jump rows without + // waiting for the next show. Scoped to just the entity kinds this + // panel actually renders (workflow/run for frecency+the row list, + // request/list/mcpserver for the Configure jump rows); 'run' also + // refreshes frecency since a new run changes MostUsed's ranking. + useEffect(() => { + return Events.On('mill-data-changed', (evt) => { + const entity = (evt.data as { entity?: string })?.entity + if (entity === 'workflow') void refreshWorkflows() + if (entity === 'run') { void refreshWorkflows(); refreshFrecency() } + if (entity === 'request') void refreshRequests() + if (entity === 'list') void refreshLists() + if (entity === 'mcpserver') void refreshMCPServers() + }) + }, []) + + // Same two events + same two RPCs App.tsx's own reviewPendingCount + // effect uses -- see this state's own declaration comment above for + // why this is a second, display-only instance rather than a shared + // one. + useEffect(() => { + const refresh = () => { + Promise.all([ + ExecutionService.ListRuns().then((runs) => (runs ?? []).filter((r) => r.pending)).catch(() => []), + SettingsService.PendingMCPWrites().then((p) => p ?? []).catch(() => []), + ]).then(([guardrailPending, mcpPending]) => { + setReviewPendingCount(guardrailPending.length + mcpPending.length) + }) + } + refresh() + const offGuardrail = Events.On('guardrail-pending-changed', refresh) + const offMCP = Events.On('mcp-write-approval', refresh) + return () => { offGuardrail(); offMCP() } + }, []) + // ⌘, opens Settings directly, matching the "Open Settings · ⌘," row's // own shortcut hint -- HideOnEscape covers Escape natively (Go-side), // this is the one panel-local keydown binding that has no native @@ -108,6 +208,19 @@ export function QuickPanel() { void SettingsService.OpenMainWindow(view).catch(() => {}) } + // A Configure-entity row's "run" is a jump, not an execution: shows + // the main window navigated straight to the tab that entity lives on + // (App.tsx's useMillNavigate parses 'configure:' -- see that + // hook's own doc comment). Lands on the TAB, not the individual row + // within it -- deep-linking to one specific entity's own edit form + // would need ConfigureView's tab components to accept a selected-row + // id too, real additional scope beyond what this goal's DoR covered + // (self-contained items only); jumping to the right tab already + // answers "where do I find/edit this." + const jumpToConfigure = (tab: string) => { + openMain(`configure:${tab}`) + } + // Same RPC + RunKind CompositionView's own list-row Run button and // CommandPalette's runWorkflowTest use (ExecutionService.RunWorkflow, // RunKindTest -- docs/adr/0008's single execution path). A workflow @@ -134,7 +247,9 @@ export function QuickPanel() { const allEntries = useMemo(() => { const entries: PanelEntry[] = [] - for (const wf of workflows ?? []) { + // Frecency-sorted (goal 0015's remainder item 1) -- frequency-only, + // see mostUsedRank's own declaration comment. + for (const wf of sortWorkflowsByFrecency(workflows ?? [], mostUsedRank)) { entries.push({ id: `run:${wf.ID}`, groupId: 'workflows', @@ -145,6 +260,45 @@ export function QuickPanel() { run: () => runWorkflow(wf.ID, wf.Label), }) } + // Configure entities (goal 0015's remainder item 3): connectors + // ("Integration" tab), Lists, MCP Servers -- searchable/jumpable + // rows alongside workflows, same ENTITY_ICON per-kind leading + // visual InventoryList rows already use elsewhere (recognition, not + // confirmation), each landing on its own Configure tab via + // jumpToConfigure. + for (const req of requests ?? []) { + entries.push({ + id: `configure:integration:${req.ID}`, + groupId: 'configure', + text: req.Label, + description: 'Jump to Integration', + searchText: req.Label.toLowerCase(), + leadingVisual: ENTITY_ICON.request.Icon, + run: () => jumpToConfigure('integration'), + }) + } + for (const list of lists ?? []) { + entries.push({ + id: `configure:lists:${list.ID}`, + groupId: 'configure', + text: list.Label, + description: 'Jump to Lists', + searchText: list.Label.toLowerCase(), + leadingVisual: ENTITY_ICON.list.Icon, + run: () => jumpToConfigure('lists'), + }) + } + for (const server of mcpServers ?? []) { + entries.push({ + id: `configure:mcpservers:${server.ID}`, + groupId: 'configure', + text: server.Label, + description: 'Jump to MCP Servers', + searchText: server.Label.toLowerCase(), + leadingVisual: ENTITY_ICON.mcpserver.Icon, + run: () => jumpToConfigure('mcpservers'), + }) + } entries.push({ id: 'open-mill', groupId: 'actions', @@ -162,9 +316,26 @@ export function QuickPanel() { trailingVisual: , run: () => openMain('settings'), }) + // Pending-review count (goal 0015's remainder item 2): always + // present (unblock-yourself-in-place -- "Review" is a real jump + // target even at zero), badged with the live count once non-zero. + entries.push({ + id: 'open-review', + groupId: 'actions', + text: 'Review', + description: reviewPendingCount > 0 ? `${reviewPendingCount} pending` : 'No pending reviews', + searchText: 'review pending approval guardrail mcp write', + leadingVisual: CAPABILITY_ICON['capability-review'], + trailingVisual: reviewPendingCount > 0 ? ( + + {reviewPendingCount} + + ) : undefined, + run: () => openMain('review'), + }) return entries - // eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow closes over workflows, already listed - }, [workflows]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- runWorkflow/jumpToConfigure/openMain close over state already listed or are stable + }, [workflows, mostUsedRank, requests, lists, mcpServers, reviewPendingCount]) const filtered = filterPaletteEntries(allEntries, query) diff --git a/frontend/src/app/useMillNavigate.ts b/frontend/src/app/useMillNavigate.ts new file mode 100644 index 00000000..fd950acc --- /dev/null +++ b/frontend/src/app/useMillNavigate.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import { Events } from '@wailsio/runtime' +import type { View } from '../shared/store' + +// docs/adr/0033: the Quick Panel's (and ApprovalPrompt's) "Open in +// Mill"/jump rows live in separate Wails windows with their own React +// trees -- they can't call setView directly, so they ask the main +// window to navigate via SettingsService.OpenMainWindow, which shows/ +// focuses this window and emits 'mill-navigate'. Extracted out of +// App.tsx (CLAUDE.md's 500-line convention) since it's fully self- +// contained: one listener, one setView call, no other App.tsx state. +// +// Target strings: 'settings', 'review' (docs/goals/0023 item 1's +// ApprovalPrompt "Open in Mill" row), 'configure:' (goal 0015's +// remainder -- QuickPanel's Configure-entity jump rows land on the +// specific tab the entity lives in, e.g. 'configure:integration' for +// a connector, 'configure:lists' for a List, 'configure:mcpservers' +// for an MCP Server). Empty string ('Open Mill') means "just show the +// window," no navigation -- OpenMainWindow only emits when non-empty. +export function useMillNavigate(setView: (view: View) => void): void { + useEffect(() => { + return Events.On('mill-navigate', (evt) => { + const target = evt.data as string + if (target === 'settings') setView({ kind: 'settings' }) + else if (target === 'review') setView({ kind: 'review' }) + else if (target.startsWith('configure:')) setView({ kind: 'configure', tab: target.slice('configure:'.length) }) + }) + }, [setView]) +} diff --git a/frontend/src/app/workflowFrecency.test.ts b/frontend/src/app/workflowFrecency.test.ts new file mode 100644 index 00000000..d547b872 --- /dev/null +++ b/frontend/src/app/workflowFrecency.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { sortWorkflowsByFrecency } from './workflowFrecency' +import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models' + +// Minimal-but-valid Workflow fixture -- every field the interface +// declares, only ID/Label actually varying per test. Local to this +// test file (no existing test-fixture builder for Workflow anywhere +// in the frontend suite to reuse). +function makeWorkflow(id: string, label = id): Workflow { + return { + ID: id, + Label: label, + Description: '', + Nodes: [], + Edges: [], + Attributes: [], + BuiltIn: false, + CreatedAt: '', + UpdatedAt: '', + Disabled: false, + PublishedVersion: 0, + Versions: [], + } +} + +describe('sortWorkflowsByFrecency', () => { + it('ranks a frequently-run workflow above a never-run one', () => { + const neverRun = makeWorkflow('wf-never-run') + const frequentlyRun = makeWorkflow('wf-frequently-run') + const sorted = sortWorkflowsByFrecency([neverRun, frequentlyRun], { 'wf-frequently-run': 12 }) + expect(sorted.map((w) => w.ID)).toEqual(['wf-frequently-run', 'wf-never-run']) + }) + + it('ranks higher run counts above lower ones, not just run-vs-never', () => { + const low = makeWorkflow('wf-low') + const high = makeWorkflow('wf-high') + const sorted = sortWorkflowsByFrecency([low, high], { 'wf-low': 2, 'wf-high': 9 }) + expect(sorted.map((w) => w.ID)).toEqual(['wf-high', 'wf-low']) + }) + + it('keeps original relative order for equal (including zero) run counts -- stable sort', () => { + const a = makeWorkflow('wf-a') + const b = makeWorkflow('wf-b') + const c = makeWorkflow('wf-c') + const sorted = sortWorkflowsByFrecency([a, b, c], {}) + expect(sorted.map((w) => w.ID)).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('does not mutate the input array', () => { + const list = [makeWorkflow('wf-1'), makeWorkflow('wf-2')] + sortWorkflowsByFrecency(list, { 'wf-2': 5 }) + 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 new file mode 100644 index 00000000..c49addb8 --- /dev/null +++ b/frontend/src/app/workflowFrecency.ts @@ -0,0 +1,31 @@ +import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models' + +// Frecency ranking for the Quick Panel's workflow list (docs/goals/ +// 0015-summon-quick-invoke.md's remainder): "frequently-used float up +// without configuring anything." The usage substrate already exists -- +// ExecutionService.HomeMetrics' MostUsed (executionservice_home.go's +// mostUsedFor, goal 0014's value mirror) counts every run regardless +// of Kind/Status in a given date window and ranks by RunCount desc -- +// this is deliberately frequency-ONLY, not recency-weighted: the +// goal's own "frecency" language doesn't require recency-weighting +// beyond what the existing substrate already computes, and inventing +// a second ranking algorithm on top of an already-built one is exactly +// the kind of thing .claude/rules/architecture.md's reuse discipline +// warns against. Pins are OUT of scope here -- no pin/favorite concept +// exists anywhere in Mill yet (grepped before starting), recorded as +// its own smaller tech-debt line in docs/goals/BACKLOG.md rather than +// invented ad hoc. +// +// A pure, standalone function (not inlined in QuickPanel.tsx) so the +// sort behavior -- a run-having workflow ranks above a never-run one, +// ties keep their original order -- has real unit coverage +// (workflowFrecency.test.ts) independent of FilteredActionList's own +// rendering, per .claude/rules/testing.md. +export function sortWorkflowsByFrecency(workflows: Workflow[], runCounts: Record): Workflow[] { + // Array.prototype.sort is spec-guaranteed stable (ES2019+) -- two + // workflows with equal (or absent, both 0) run counts keep their + // original relative order rather than the comparator's tie-breaking + // becoming a coin flip, so a fresh install's alphabetical fetch order + // survives unchanged until real usage data exists to reorder it. + return [...workflows].sort((a, b) => (runCounts[b.ID] ?? 0) - (runCounts[a.ID] ?? 0)) +} diff --git a/frontend/src/configure/ConfigureView.tsx b/frontend/src/configure/ConfigureView.tsx index ce98814d..5a688c3a 100644 --- a/frontend/src/configure/ConfigureView.tsx +++ b/frontend/src/configure/ConfigureView.tsx @@ -22,9 +22,16 @@ import { ConfigureExecEnv } from './ConfigureExecEnv' // wrappers, Tabs.tsx) -- every panel stays mounted (a `hidden` // attribute toggles, not unmount), so switching tabs never loses // in-progress form state in the others. -function ConfigureView() { +// +// initialTab (goal 0015's remainder): QuickPanel's Configure-entity +// jump rows land here on the specific tab an entity lives in, via +// App.tsx's `key={view.tab}` forcing a fresh mount (Tabs is +// uncontrolled -- defaultValue only applies on mount, so an +// already-open Configure view wouldn't otherwise re-select on a +// second jump to a different tab). +function ConfigureView({ initialTab }: { initialTab?: string }) { return ( - + Integration Lists diff --git a/frontend/src/shared/inventorySort.ts b/frontend/src/shared/inventorySort.ts index 510d9c06..49b092d0 100644 --- a/frontend/src/shared/inventorySort.ts +++ b/frontend/src/shared/inventorySort.ts @@ -63,11 +63,25 @@ const relativeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) // is exactly what Intl already solves). Falls back to // toLocaleDateString beyond ~7 days, where a relative phrase stops // being more useful than an actual date. -export function formatUpdated(ts: unknown): string { +// +// now defaults to the real clock (every UI call site wants that) but is +// a real parameter, not a hardcoded Date.now() -- found live in this +// session (not part of this goal's own scope, fixed here as a small, +// CI-blocking out-of-goal fix per CLAUDE.md): shared/staleness.ts's +// formatLastChecked already accepted its own `now` override for +// deterministic testing, but silently dropped it calling this function +// with one argument, so the override was pure decoration -- the +// second real clock is what actually decided the output. +// staleness.test.ts's own hardcoded NOW anchor (2026-08-11) caught this +// for real once the wall clock actually crossed a day boundary past it +// mid-session, flipping a "9 minutes ago" expectation to "yesterday" -- +// exactly the class of bug a real `now` parameter, honored end to end, +// prevents. +export function formatUpdated(ts: unknown, now: number = Date.now()): string { const ms = parseUpdated(ts) if (ms === null) return '' - const diffMs = ms - Date.now() + const diffMs = ms - now const absDiffMs = Math.abs(diffMs) if (absDiffMs < 1000 * 60 * 60 * 24 * 7) { diff --git a/frontend/src/shared/staleness.ts b/frontend/src/shared/staleness.ts index 6660eef6..0b590472 100644 --- a/frontend/src/shared/staleness.ts +++ b/frontend/src/shared/staleness.ts @@ -106,6 +106,6 @@ export function isPollStale( // rather than a second Intl.RelativeTimeFormat instance. export function formatLastChecked(lastPolledAt: string | Date | null | undefined, now: number = Date.now()): string { if (!isPollStale(lastPolledAt, now)) return '' - const rel = formatUpdated(lastPolledAt as string) + const rel = formatUpdated(lastPolledAt as string, now) return rel ? `requester last checked ${rel}` : '' } diff --git a/frontend/src/shared/store.ts b/frontend/src/shared/store.ts index ae2d48eb..f6c3d1f7 100644 --- a/frontend/src/shared/store.ts +++ b/frontend/src/shared/store.ts @@ -68,7 +68,13 @@ export type View = | { kind: 'activity' } | { kind: 'review' } | { kind: 'composition' } - | { kind: 'configure' } + // tab (goal 0015's remainder, QuickPanel's Configure-entity jump + // rows): which ConfigureView sub-tab to land on ('integration' / + // 'lists' / 'mcpservers' / ...), undefined = ConfigureView's own + // default. Optional so every existing `{ kind: 'configure' }` call + // site (sidebar nav, the ⌘K palette's view.configure command) stays + // valid unchanged -- landing on whichever tab was last open there. + | { kind: 'configure'; tab?: string } | { kind: 'settings' } | { kind: 'placeholder'; capabilityId: string }