From bc204d36034407cc8a1f05ddb83efa8b016b1b08 Mon Sep 17 00:00:00 2001 From: Dune Developer 6 Date: Sat, 25 Apr 2026 01:28:13 +0800 Subject: [PATCH] Add work item search and filters --- src/renderer/app/AppShell.tsx | 9 + src/renderer/app/shell/CommandMenu.tsx | 61 ++++++- .../app/workspaces/WorkflowWorkspace.tsx | 53 +++++- .../workflow/components/FilterPanel.tsx | 156 +++++++++++++++++ .../features/workflow/model/search-index.ts | 164 ++++++++++++++++++ .../workflow/model/workflow-filters.ts | 107 ++++++++++++ .../renderer/app/shell/CommandMenu.test.tsx | 1 + .../workflow/model/search-index.test.ts | 92 ++++++++++ .../workflow/model/workflow-filters.test.ts | 76 ++++++++ 9 files changed, 707 insertions(+), 12 deletions(-) create mode 100644 src/renderer/features/workflow/components/FilterPanel.tsx create mode 100644 src/renderer/features/workflow/model/search-index.ts create mode 100644 src/renderer/features/workflow/model/workflow-filters.ts create mode 100644 tests/unit/src/renderer/features/workflow/model/search-index.test.ts create mode 100644 tests/unit/src/renderer/features/workflow/model/workflow-filters.test.ts diff --git a/src/renderer/app/AppShell.tsx b/src/renderer/app/AppShell.tsx index 13802a6..bcc68dd 100644 --- a/src/renderer/app/AppShell.tsx +++ b/src/renderer/app/AppShell.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -52,6 +53,7 @@ import { cn } from '@/renderer/shared/lib/utils'; import { Button } from '@/renderer/shared/ui/button'; import { TooltipProvider } from '@/renderer/shared/ui/tooltip'; import { WorkflowProjectActionsMenu } from '@/renderer/features/workflow/components/WorkflowProjectActionsMenu'; +import { createSearchIndex } from '@/renderer/features/workflow/model/search-index'; import { MAC_TITLEBAR_OVERLAY_HEIGHT, MAC_TITLEBAR_SIDEBAR_TOGGLE_GAP, @@ -108,13 +110,19 @@ export default function AppShell() { agents, appendTranscriptPage, clearAgentAssignments, + allWorkflowItems, } = useAppStore( useShallow((state) => ({ agents: state.agents, appendTranscriptPage: state.appendTranscriptPage, + allWorkflowItems: state.items, clearAgentAssignments: state.clearAgentAssignments, })), ); + const searchIndex = useMemo( + () => createSearchIndex(allWorkflowItems, agents, projects), + [agents, allWorkflowItems, projects], + ); const showContextPanel = route === 'agent' && isContextPanelOpen && !!activeAgent; const titlebarAreaRect = useWindowControlsOverlay(isMac); const { isCompactShell, usesInlineContext, usesOverlayContext } = @@ -602,6 +610,7 @@ export default function AppShell() { onToggleContextPanel={controller.handleToggleContextPanel} open={isCommandOpen} projects={projects} + searchIndex={searchIndex} /> void; open: boolean; projects: CommandProject[]; + searchIndex: SearchIndex; } /** Renders the command menu UI. */ @@ -83,21 +90,69 @@ export function CommandMenu({ onToggleContextPanel, open, projects, + searchIndex, }: CommandMenuProps) { const { modifierLabel } = useDesktopPlatform(); + const [query, setQuery] = useState(''); + const searchResults = useMemo( + () => searchIndex.search(query, 10), + [query, searchIndex], + ); + const isSearching = query.trim().length > 0; const closeAndRun = (handler: () => void) => { onOpenChange(false); + setQuery(''); startTransition(() => { handler(); }); }; return ( - - + { + if (!nextOpen) { + setQuery(''); + } + + onOpenChange(nextOpen); + }} + open={open} + > + No matching projects, work items, or actions. + {isSearching ? ( + <> + + {searchResults.map((item) => ( + closeAndRun(() => onSelectItem(item.itemId))} + value={`${item.title} ${item.statusLabel} ${item.assigneeName ?? ''} ${item.snippet}`} + > + +
+ {item.title} + + {item.statusLabel} · {item.assigneeName ?? 'No agent'} · {item.projectName} + + + {item.snippet} + +
+
+ ))} +
+ + + + ) : null} + closeAndRun(onCreateItem)}> diff --git a/src/renderer/app/workspaces/WorkflowWorkspace.tsx b/src/renderer/app/workspaces/WorkflowWorkspace.tsx index 16a9b75..875de0a 100644 --- a/src/renderer/app/workspaces/WorkflowWorkspace.tsx +++ b/src/renderer/app/workspaces/WorkflowWorkspace.tsx @@ -4,7 +4,10 @@ import { Plus, X, } from 'lucide-react'; -import { useState } from 'react'; +import { + useMemo, + useState, +} from 'react'; import { useShallow } from 'zustand/react/shallow'; import { CompactShellToolbar } from '@/renderer/app/shell/CompactShellToolbar'; @@ -13,6 +16,7 @@ import { useWorkflowSession } from '@/renderer/app/store/selectors'; import { useAppStore } from '@/renderer/app/store/use-app-store'; import { CreateProjectDialog } from '@/renderer/features/workflow/components/CreateProjectDialog'; import { CreateWorkItemDialog } from '@/renderer/features/workflow/components/CreateWorkItemDialog'; +import { FilterPanel } from '@/renderer/features/workflow/components/FilterPanel'; import { WorkflowBoard } from '@/renderer/features/workflow/components/WorkflowBoard'; import { WorkflowItemInspector } from '@/renderer/features/workflow/components/WorkflowItemInspector'; import { WorkflowProjectActivity } from '@/renderer/features/workflow/components/WorkflowProjectActivity'; @@ -20,6 +24,10 @@ import { WorkflowProjectActionsMenu } from '@/renderer/features/workflow/compone import { WorkflowProjectAgents } from '@/renderer/features/workflow/components/WorkflowProjectAgents'; import { WorkflowProjectSettings } from '@/renderer/features/workflow/components/WorkflowProjectSettings'; import { presentWorkflowEventTimestamp } from '@/renderer/features/workflow/model/workflow-presenters'; +import { + defaultWorkflowItemFilters, + filterWorkflowItems, +} from '@/renderer/features/workflow/model/workflow-filters'; import { agentRuntime } from '@/renderer/features/agents/runtime/agent-runtime'; import { Dialog, @@ -71,6 +79,7 @@ export function WorkflowWorkspace({ }: WorkflowWorkspaceProps) { const commands = useAppCommands(); const [isDeleteProjectOpen, setDeleteProjectOpen] = useState(false); + const [workflowItemFilters, setWorkflowItemFilters] = useState(defaultWorkflowItemFilters); const [cachedActivityEntriesByProjectId, setCachedActivityEntriesByProjectId] = useState>>({}); @@ -108,6 +117,7 @@ export function WorkflowWorkspace({ activityEntries, activitySummary, filteredItemSummaries, + items, isWorkflowHydrated, projectAgents, projects, @@ -136,6 +146,29 @@ export function WorkflowWorkspace({ ...activitySummary, hasOlderEntries: mergedActivityEntries.length < activitySummary.totalEntryCount, }; + const boardItemSummaries = useMemo(() => { + const baseFilteredItemIds = new Set(filteredItemSummaries.map((item) => item.id)); + const advancedFilteredItemIds = new Set( + filterWorkflowItems( + items.filter((item) => baseFilteredItemIds.has(item.id)), + workflowItemFilters, + ).map((item) => item.id), + ); + + return filteredItemSummaries.filter((item) => advancedFilteredItemIds.has(item.id)); + }, [filteredItemSummaries, items, workflowItemFilters]); + const boardFilterPanel = ( + ({ + id: agent.id, + name: agent.name, + }))} + filters={workflowItemFilters} + matchCount={boardItemSummaries.length} + onChange={setWorkflowItemFilters} + totalCount={filteredItemSummaries.length} + /> + ); if (!isWorkflowHydrated) { return ( @@ -155,7 +188,7 @@ export function WorkflowWorkspace({ } /** Handles primary agent assignment. Persisting the snapshot triggers the main-process scheduler. */ - const handleAssignPrimaryAgent = async ( + const handleAssignPrimaryAgent = ( itemId: string, input: { agentId: string | null; agentName?: string | null }, ) => { @@ -184,7 +217,7 @@ export function WorkflowWorkspace({ { openRoute: false }, ); - await handleAssignPrimaryAgent(itemId, { + handleAssignPrimaryAgent(itemId, { agentId, agentName: suggestedName, }); @@ -279,9 +312,10 @@ export function WorkflowWorkspace({ const boardView = ( <> {isCompactShell ? ( -
+
+ {boardFilterPanel} { selectItem(itemId); @@ -291,9 +325,10 @@ export function WorkflowWorkspace({
) : (
-
+
+ {boardFilterPanel} { selectItem(itemId); @@ -312,7 +347,7 @@ export function WorkflowWorkspace({ addTask(itemId, title); }} onAssignPrimaryAgent={(itemId, input) => { - void handleAssignPrimaryAgent(itemId, input); + handleAssignPrimaryAgent(itemId, input); }} onCreateAgent={(itemId) => { void handleCreateAgentForItem(itemId); @@ -343,7 +378,7 @@ export function WorkflowWorkspace({ addTask(itemId, title); }} onAssignPrimaryAgent={(itemId, input) => { - void handleAssignPrimaryAgent(itemId, input); + handleAssignPrimaryAgent(itemId, input); }} onCreateAgent={(itemId) => { void handleCreateAgentForItem(itemId); diff --git a/src/renderer/features/workflow/components/FilterPanel.tsx b/src/renderer/features/workflow/components/FilterPanel.tsx new file mode 100644 index 0000000..35cd5a1 --- /dev/null +++ b/src/renderer/features/workflow/components/FilterPanel.tsx @@ -0,0 +1,156 @@ +// Workflow item filter panel. + +import { RotateCcw } from 'lucide-react'; + +import { + defaultWorkflowItemFilters, + hasActiveWorkflowItemFilters, + type WorkflowItemFilters, +} from '@/renderer/features/workflow/model/workflow-filters'; +import { + workflowItemStatusLabels, +} from '@/renderer/features/workflow/model/workflow-presenters'; +import { + workflowItemStatuses, +} from '@/renderer/features/workflow/types'; +import { Button } from '@/renderer/shared/ui/button'; +import { Input } from '@/renderer/shared/ui/input'; +import { cn } from '@/renderer/shared/lib/utils'; + +/** Filter panel agent shape. */ +interface FilterPanelAgent { + id: string; + name: string; +} + +/** Filter panel props. */ +interface FilterPanelProps { + agents: FilterPanelAgent[]; + filters: WorkflowItemFilters; + matchCount: number; + onChange: (filters: WorkflowItemFilters) => void; + totalCount: number; +} + +const selectClassName = + 'focus-ring-app h-11 rounded-[16px] border border-app-border bg-app-panel px-3 text-sm text-app-text outline-none transition-colors focus-visible:border-app-border-strong focus-visible:ring-2'; + +/** Renders workflow board filters. */ +export function FilterPanel({ + agents, + filters, + matchCount, + onChange, + totalCount, +}: FilterPanelProps) { + const hasActiveFilters = hasActiveWorkflowItemFilters(filters); + + const updateFilters = (nextFilters: Partial) => { + onChange({ + ...filters, + ...nextFilters, + }); + }; + + return ( +
+ + + + + + + + + + +
+ + {matchCount}/{totalCount} shown + + +
+
+ ); +} diff --git a/src/renderer/features/workflow/model/search-index.ts b/src/renderer/features/workflow/model/search-index.ts new file mode 100644 index 0000000..28a2f78 --- /dev/null +++ b/src/renderer/features/workflow/model/search-index.ts @@ -0,0 +1,164 @@ +// In-memory work item search index. + +import type { Agent } from '@/renderer/features/agents/types'; +import { + formatWorkflowItemStatus, +} from '@/renderer/features/workflow/model/workflow-presenters'; +import type { + WorkflowItem, + WorkflowItemStatus, + WorkflowProject, +} from '@/renderer/features/workflow/types'; + +/** Search result shape. */ +export interface WorkItemSearchResult { + assigneeName: string | null; + itemId: string; + projectName: string; + snippet: string; + status: WorkflowItemStatus; + statusLabel: string; + title: string; +} + +interface SearchDocument { + assigneeName: string | null; + createdAt: number; + itemId: string; + projectName: string; + searchableText: string; + searchableTextLower: string; + status: WorkflowItemStatus; + title: string; + updatedAt: number; +} + +/** Work item search index. */ +export interface SearchIndex { + search: (query: string, limit?: number) => WorkItemSearchResult[]; +} + +/** Normalizes search input. */ +function normalizeQuery(query: string) { + return query + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); +} + +/** Builds indexed text for one item. */ +function getSearchableText(item: WorkflowItem) { + return [ + item.title, + item.brief, + ...item.workProducts.flatMap((product) => [product.title, product.body]), + ] + .filter(Boolean) + .join('\n'); +} + +/** Creates a compact match snippet. */ +function createSnippet(text: string, terms: string[]) { + const collapsedText = text.replace(/\s+/g, ' ').trim(); + + if (!collapsedText) { + return 'No matching text.'; + } + + const lowerText = collapsedText.toLowerCase(); + const firstMatchIndex = terms.reduce((currentIndex, term) => { + const matchIndex = lowerText.indexOf(term); + + if (matchIndex === -1) { + return currentIndex; + } + + return currentIndex === null ? matchIndex : Math.min(currentIndex, matchIndex); + }, null); + + if (firstMatchIndex === null) { + return collapsedText.slice(0, 140); + } + + const start = Math.max(0, firstMatchIndex - 52); + const end = Math.min(collapsedText.length, firstMatchIndex + 108); + const prefix = start > 0 ? '...' : ''; + const suffix = end < collapsedText.length ? '...' : ''; + + return `${prefix}${collapsedText.slice(start, end)}${suffix}`; +} + +/** Scores document match quality. */ +function scoreDocument(document: SearchDocument, terms: string[]) { + const titleLower = document.title.toLowerCase(); + let score = 0; + + for (const term of terms) { + if (titleLower === term) { + score += 120; + } else if (titleLower.startsWith(term)) { + score += 80; + } else if (titleLower.includes(term)) { + score += 55; + } else { + score += 12; + } + } + + return score + Math.floor(document.updatedAt / 1_000_000_000); +} + +/** Creates a local work item search index. */ +export function createSearchIndex( + items: WorkflowItem[], + agents: Agent[], + projects: WorkflowProject[], +): SearchIndex { + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); + const projectsById = new Map(projects.map((project) => [project.id, project] as const)); + const documents: SearchDocument[] = items.map((item) => { + const searchableText = getSearchableText(item); + const assigneeName = item.primaryAgentId + ? agentsById.get(item.primaryAgentId)?.name ?? null + : null; + + return { + assigneeName, + createdAt: item.createdAt, + itemId: item.id, + projectName: projectsById.get(item.projectId)?.name ?? 'Project', + searchableText, + searchableTextLower: searchableText.toLowerCase(), + status: item.status, + title: item.title, + updatedAt: item.updatedAt, + }; + }); + + return { + search: (query, limit = 8) => { + const terms = normalizeQuery(query); + + if (terms.length === 0) { + return []; + } + + return documents + .filter((document) => + terms.every((term) => document.searchableTextLower.includes(term)), + ) + .sort((left, right) => scoreDocument(right, terms) - scoreDocument(left, terms)) + .slice(0, limit) + .map((document) => ({ + assigneeName: document.assigneeName, + itemId: document.itemId, + projectName: document.projectName, + snippet: createSnippet(document.searchableText, terms), + status: document.status, + statusLabel: formatWorkflowItemStatus(document.status), + title: document.title, + })); + }, + }; +} diff --git a/src/renderer/features/workflow/model/workflow-filters.ts b/src/renderer/features/workflow/model/workflow-filters.ts new file mode 100644 index 0000000..458aac6 --- /dev/null +++ b/src/renderer/features/workflow/model/workflow-filters.ts @@ -0,0 +1,107 @@ +// Workflow board filter helpers. + +import type { + WorkflowItem, + WorkflowItemStatus, +} from '@/renderer/features/workflow/types'; + +/** Reviewer feedback filter value. */ +export type ReviewerFilter = 'all' | 'has' | 'none'; + +/** Workflow board filter state. */ +export interface WorkflowItemFilters { + agentId: 'all' | 'unassigned' | string; + dateFrom: string; + dateTo: string; + reviewer: ReviewerFilter; + status: 'all' | WorkflowItemStatus; +} + +/** Default workflow board filters. */ +export const defaultWorkflowItemFilters: WorkflowItemFilters = { + agentId: 'all', + dateFrom: '', + dateTo: '', + reviewer: 'all', + status: 'all', +}; + +/** Checks whether filter state is default. */ +export function hasActiveWorkflowItemFilters(filters: WorkflowItemFilters) { + return ( + filters.agentId !== defaultWorkflowItemFilters.agentId || + filters.dateFrom !== defaultWorkflowItemFilters.dateFrom || + filters.dateTo !== defaultWorkflowItemFilters.dateTo || + filters.reviewer !== defaultWorkflowItemFilters.reviewer || + filters.status !== defaultWorkflowItemFilters.status + ); +} + +/** Converts yyyy-mm-dd input to day boundary timestamp. */ +function parseDateInput(value: string, boundary: 'end' | 'start') { + if (!value) { + return null; + } + + const date = new Date(`${value}T00:00:00`); + + if (Number.isNaN(date.getTime())) { + return null; + } + + if (boundary === 'end') { + date.setHours(23, 59, 59, 999); + } + + return date.getTime(); +} + +/** Returns whether an item has reviewer feedback. */ +function hasReviewerFeedback(item: WorkflowItem) { + return item.workflowEvents.some((event) => event.kind === 'feedback'); +} + +/** Applies combinable workflow item filters. */ +export function filterWorkflowItems( + items: WorkflowItem[], + filters: WorkflowItemFilters, +) { + const fromTimestamp = parseDateInput(filters.dateFrom, 'start'); + const toTimestamp = parseDateInput(filters.dateTo, 'end'); + + return items.filter((item) => { + if (filters.status !== 'all' && item.status !== filters.status) { + return false; + } + + if (filters.agentId === 'unassigned' && item.primaryAgentId) { + return false; + } + + if ( + filters.agentId !== 'all' && + filters.agentId !== 'unassigned' && + item.primaryAgentId !== filters.agentId + ) { + return false; + } + + if (fromTimestamp !== null && item.updatedAt < fromTimestamp) { + return false; + } + + if (toTimestamp !== null && item.updatedAt > toTimestamp) { + return false; + } + + if (filters.reviewer === 'has' && !hasReviewerFeedback(item)) { + return false; + } + + if (filters.reviewer === 'none' && hasReviewerFeedback(item)) { + return false; + } + + return true; + }); +} diff --git a/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx b/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx index 18fd88b..4e3ea1c 100644 --- a/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx +++ b/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx @@ -34,6 +34,7 @@ describe('CommandMenu', () => { onToggleContextPanel={vi.fn()} open projects={[]} + searchIndex={{ search: () => [] }} />, ); diff --git a/tests/unit/src/renderer/features/workflow/model/search-index.test.ts b/tests/unit/src/renderer/features/workflow/model/search-index.test.ts new file mode 100644 index 0000000..079826e --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/model/search-index.test.ts @@ -0,0 +1,92 @@ +// Work item search index tests. + +import { describe, expect, it } from 'vitest'; + +import { createSearchIndex } from '@/renderer/features/workflow/model/search-index'; +import type { Agent } from '@/renderer/features/agents/types'; +import type { + WorkflowItem, + WorkflowProject, +} from '@/renderer/features/workflow/types'; + +function createItem(overrides: Partial): WorkflowItem { + return { + activity: { + archivedEventCount: 0, + hasOlderEvents: false, + rollingSummary: null, + totalEventCount: 0, + }, + artifactFolderName: 'item-one', + brief: '', + createdAt: 1, + id: 'item-1', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'inbox', + tasks: [], + title: 'Untitled', + updatedAt: 1, + workProducts: [], + workflowEvents: [], + ...overrides, + }; +} + +const agents = [ + { + id: 'agent-1', + name: 'Dune Repo Lead', + }, +] as Agent[]; + +const projects: WorkflowProject[] = [ + { + color: '#000000', + createdAt: 1, + description: '', + id: 'project-1', + name: 'Desktop App', + rootPath: null, + updatedAt: 1, + }, +]; + +describe('createSearchIndex', () => { + it('matches work product content and returns the work item metadata', () => { + const index = createSearchIndex( + [ + createItem({ + brief: 'Improve command workflows.', + primaryAgentId: 'agent-1', + status: 'review', + title: 'Command palette polish', + workProducts: [ + { + body: 'The generated artifact documents keyboard navigation behavior.', + createdAt: 1, + id: 'product-1', + title: 'Implementation notes', + }, + ], + }), + ], + agents, + projects, + ); + + const results = index.search('keyboard navigation'); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + assigneeName: 'Dune Repo Lead', + itemId: 'item-1', + projectName: 'Desktop App', + statusLabel: 'Review', + title: 'Command palette polish', + }); + expect(results[0]?.snippet).toContain('keyboard navigation'); + }); +}); diff --git a/tests/unit/src/renderer/features/workflow/model/workflow-filters.test.ts b/tests/unit/src/renderer/features/workflow/model/workflow-filters.test.ts new file mode 100644 index 0000000..678277d --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/model/workflow-filters.test.ts @@ -0,0 +1,76 @@ +// Workflow filter tests. + +import { describe, expect, it } from 'vitest'; + +import { + filterWorkflowItems, + type WorkflowItemFilters, +} from '@/renderer/features/workflow/model/workflow-filters'; +import type { WorkflowItem } from '@/renderer/features/workflow/types'; + +function createItem(overrides: Partial): WorkflowItem { + return { + activity: { + archivedEventCount: 0, + hasOlderEvents: false, + rollingSummary: null, + totalEventCount: 0, + }, + artifactFolderName: 'item-one', + brief: '', + createdAt: 1, + id: 'item-1', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'inbox', + tasks: [], + title: 'Untitled', + updatedAt: new Date('2026-01-15T12:00:00').getTime(), + workProducts: [], + workflowEvents: [], + ...overrides, + }; +} + +describe('filterWorkflowItems', () => { + it('combines status, agent, date, and reviewer filters', () => { + const matchingItem = createItem({ + id: 'item-match', + primaryAgentId: 'agent-1', + status: 'review', + workflowEvents: [ + { + createdAt: 1, + description: 'Looks ready.', + id: 'event-1', + kind: 'feedback', + }, + ], + }); + const wrongAgentItem = createItem({ + id: 'item-wrong-agent', + primaryAgentId: 'agent-2', + status: 'review', + workflowEvents: [ + { + createdAt: 1, + description: 'Looks ready.', + id: 'event-2', + kind: 'feedback', + }, + ], + }); + const filters: WorkflowItemFilters = { + agentId: 'agent-1', + dateFrom: '2026-01-01', + dateTo: '2026-01-31', + reviewer: 'has', + status: 'review', + }; + + expect(filterWorkflowItems([matchingItem, wrongAgentItem], filters).map((item) => item.id)) + .toEqual(['item-match']); + }); +});