From 845e90d7c055062470f0bf7c53a5a8bf3b650c34 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 14:00:51 +0000 Subject: [PATCH] [beadboard] Add quality filters for acceptance criteria and description length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The left-panel task list surfaces every bead regardless of whether the task has been specified thoroughly enough for an agent to pick it up. Tasks with empty `acceptance_criteria` or very short descriptions are low-signal noise that muddles the navigation spine — especially now that we plan to dispatch tasks to Claude agents (which need concrete instructions to succeed). Epics are deliberately exempt: their role is grouping, not execution, so requiring acceptance criteria / long descriptions on epics would hide the entire backbone of the navigation. ## This change - Plumbs `acceptance_criteria` end-to-end: adds the optional field on `BeadIssue`, and reads it both from `.beads/issues.jsonl` (parser) and Dolt SQL (`read-issues-dolt.ts` row shape + normalizer). - Extends `LeftPanelFilters` with `hideNoAcceptance` and `hideShortDescription` (both default `true`) in the source-of-truth `hooks/use-url-state.ts` and in the re-exported shadow type on `components/shared/left-panel.tsx`. - Updates `isTaskMatch` in both `left-panel.tsx` and `left-panel-new.tsx` to skip non-epic tasks lacking acceptance criteria or with a description shorter than `SHORT_DESCRIPTION_MIN_LENGTH` (200 chars). Epics bypass both filters via the `issue_type === 'epic'` guard. - Exposes `isTaskMatch` from `left-panel.tsx` so the filter tests can assert behavior directly (previously only `shouldHideEpicEntry` was exported). - Adds two checkboxes under the existing "Hide Closed" button in both left-panel variants (legacy `left-panel.tsx` and the one unified-shell currently wires up — `left-panel-new.tsx`). - Seeds both new filter flags as `true` in the `UnifiedShell` default state so fresh sessions see the high-signal view without toggling. ## What is NOT in this change - No mutation of `bd` / CLI behavior. Filters are purely UI-level. - No localStorage persistence for the two new flags — existing `hideClosed` is also React-only, so parity is preserved. If/when we persist any of these, all three move together. - No change to the `metadata.acceptance` path used by `kanban.ts`'s `hasQualitySignal` — that's a separate signal with its own callers. ## Test Plan ### Automated Tests run from `/home/wizard/code/beadboard`: ``` $ node --import tsx --test tests/components/shared/left-panel-filtering.test.ts # tests 15 # pass 15 # fail 0 # duration_ms 604 ``` All 15 filter cases pass: 6 pre-existing `shouldHideEpicEntry` cases plus 9 new `isTaskMatch` cases covering acceptance-criteria-empty hides, acceptance-criteria-disabled shows, epic exemption, 199/200-char description boundary, null description, and short-description flag disabled. Related suites still green: ``` $ node --import tsx --test tests/components/shared/left-panel.test.tsx \ tests/components/shared/unified-shell-hide-closed-contract.test.ts # tests 7 pass 7 fail 0 ``` Pre-existing failures in `tests/hooks/url-state-integration.test.ts` (`view=activity` cases) and one pre-existing typecheck error in `left-panel.tsx` thread prop are unrelated — both reproduce on `main` before this change. ### Manual Verification 1. `npm install` 2. `npm run dev` 3. Open `http://localhost:3000` with bd project loaded. 4. Expected: two new checkboxes appear under "Hide Closed" in the left sidebar — "Hide tasks without acceptance criteria" and "Hide tasks with short description (<200 chars)". Both checked by default. 5. Toggle each off. Expected: additional beads appear in epic expansions (tasks that were previously hidden because they lack quality signal). 6. Confirm epics remain visible regardless of the checkbox state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/shared/left-panel-new.tsx | 26 ++++++ src/components/shared/left-panel.tsx | 29 ++++++- src/components/shared/unified-shell.tsx | 2 + src/hooks/use-url-state.ts | 14 +++- src/lib/parser.ts | 1 + src/lib/read-issues-dolt.ts | 2 + src/lib/types.ts | 2 + .../shared/left-panel-filtering.test.ts | 80 ++++++++++++++++++- 8 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/components/shared/left-panel-new.tsx b/src/components/shared/left-panel-new.tsx index 735da53..69daf07 100644 --- a/src/components/shared/left-panel-new.tsx +++ b/src/components/shared/left-panel-new.tsx @@ -51,6 +51,8 @@ const views = [ { id: 'graph', label: 'Graph' }, ] as const; +const SHORT_DESCRIPTION_MIN_LENGTH = 200; + function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean { if (filters.query.trim()) { const query = filters.query.toLowerCase(); @@ -80,6 +82,10 @@ function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean { if (task.status === 'closed' || task.status === 'tombstone') return false; } + const isEpic = task.issue_type === 'epic'; + if (!isEpic && filters.hideNoAcceptance && !task.acceptance_criteria?.trim()) return false; + if (!isEpic && filters.hideShortDescription && (task.description ?? '').length < SHORT_DESCRIPTION_MIN_LENGTH) return false; + return true; } @@ -321,6 +327,26 @@ export function LeftPanel({ > Hide Closed + +
diff --git a/src/components/shared/left-panel.tsx b/src/components/shared/left-panel.tsx index 14607cd..2591388 100644 --- a/src/components/shared/left-panel.tsx +++ b/src/components/shared/left-panel.tsx @@ -19,8 +19,12 @@ export interface LeftPanelFilters { priority: LeftPanelPriorityFilter; preset: LeftPanelPresetFilter; hideClosed: boolean; + hideNoAcceptance: boolean; + hideShortDescription: boolean; } +export const SHORT_DESCRIPTION_MIN_LENGTH = 200; + export interface LeftPanelProps { issues: BeadIssue[]; selectedEpicId?: string | null; @@ -185,8 +189,11 @@ function rowTone(entry: EpicEntry): string { return 'var(--surface-tertiary)'; } -function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean { +export function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean { if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false; + const isEpic = task.issue_type === 'epic'; + if (!isEpic && filters.hideNoAcceptance && !task.acceptance_criteria?.trim()) return false; + if (!isEpic && filters.hideShortDescription && (task.description ?? '').length < SHORT_DESCRIPTION_MIN_LENGTH) return false; const normalizedQuery = filters.query.trim().toLowerCase(); if (normalizedQuery.length > 0) { const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase(); @@ -333,6 +340,26 @@ export function LeftPanel({ > Hide Closed + +
diff --git a/src/components/shared/unified-shell.tsx b/src/components/shared/unified-shell.tsx index 7e3cee5..a267128 100644 --- a/src/components/shared/unified-shell.tsx +++ b/src/components/shared/unified-shell.tsx @@ -67,6 +67,8 @@ export function UnifiedShell({ priority: 'all', preset: 'all', hideClosed: true, + hideNoAcceptance: true, + hideShortDescription: true, }); const [actor, setActor] = useState(''); diff --git a/src/hooks/use-url-state.ts b/src/hooks/use-url-state.ts index 1513341..400eb8a 100644 --- a/src/hooks/use-url-state.ts +++ b/src/hooks/use-url-state.ts @@ -8,8 +8,18 @@ export type PanelState = 'open' | 'closed'; export type DrawerState = 'open' | 'closed'; export type GraphTabType = 'flow' | 'overview'; export type LeftSidebarMode = 'epics' | 'orchestrator'; -export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';export interface LeftPanelFilters { status: LeftPanelStatusFilter; priority: LeftPanelPriorityFilter; preset: LeftPanelPresetFilter; hideClosed: boolean; - query: string;} +export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done'; +export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; +export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents'; +export interface LeftPanelFilters { + status: LeftPanelStatusFilter; + priority: LeftPanelPriorityFilter; + preset: LeftPanelPresetFilter; + hideClosed: boolean; + query: string; + hideNoAcceptance: boolean; + hideShortDescription: boolean; +} export interface UrlState { view: ViewType; diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 5d6a434..d90390d 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -58,6 +58,7 @@ function normalizeIssue(raw: ParseableBeadIssue): BeadIssue { due_at: typeof raw.due_at === 'string' ? raw.due_at : null, estimated_minutes: typeof raw.estimated_minutes === 'number' ? raw.estimated_minutes : null, external_ref: typeof raw.external_ref === 'string' ? raw.external_ref : null, + acceptance_criteria: typeof raw.acceptance_criteria === 'string' ? raw.acceptance_criteria : null, metadata: typeof raw.metadata === 'object' && raw.metadata !== null ? (raw.metadata as Record) : {}, }; } diff --git a/src/lib/read-issues-dolt.ts b/src/lib/read-issues-dolt.ts index ffccabe..407adf4 100644 --- a/src/lib/read-issues-dolt.ts +++ b/src/lib/read-issues-dolt.ts @@ -8,6 +8,7 @@ interface IssueRow extends RowDataPacket { id: string; title: string; description: string | null; + acceptance_criteria: string | null; status: string; priority: number; issue_type: string; @@ -62,6 +63,7 @@ function normalizeRow(row: IssueRow, deps: BeadDependency[]): BeadIssue { estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null, external_ref: row.external_ref ?? null, comments_count: (row.comments_count ?? 0) as number, + acceptance_criteria: row.acceptance_criteria ?? null, metadata: row.metadata ?? {}, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0c8ae60..c528307 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -58,6 +58,8 @@ export interface BeadIssue { agentTypeId?: string; /** Which specific agent instance is assigned (if running) */ agentInstanceId?: string; + /** Free-form acceptance criteria captured by bd create --acceptance */ + acceptance_criteria?: string | null; metadata: Record; } diff --git a/tests/components/shared/left-panel-filtering.test.ts b/tests/components/shared/left-panel-filtering.test.ts index e714e88..40ce99a 100644 --- a/tests/components/shared/left-panel-filtering.test.ts +++ b/tests/components/shared/left-panel-filtering.test.ts @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { shouldHideEpicEntry, type LeftPanelFilters } from '../../../src/components/shared/left-panel'; +import { isTaskMatch, shouldHideEpicEntry, type LeftPanelFilters } from '../../../src/components/shared/left-panel'; +import type { BeadIssue } from '../../../src/lib/types'; const defaultFilters: LeftPanelFilters = { query: '', @@ -9,8 +10,38 @@ const defaultFilters: LeftPanelFilters = { priority: 'all', preset: 'all', hideClosed: true, + hideNoAcceptance: true, + hideShortDescription: true, }; +function makeIssue(overrides: Partial = {}): BeadIssue { + return { + id: 'test-1', + title: 'Test issue', + description: 'x'.repeat(400), + status: 'open', + priority: 2, + issue_type: 'task', + assignee: null, + templateId: null, + owner: null, + labels: [], + dependencies: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + closed_at: null, + close_reason: null, + closed_by_session: null, + created_by: null, + due_at: null, + estimated_minutes: null, + external_ref: null, + acceptance_criteria: 'Some acceptance criteria text', + metadata: {}, + ...overrides, + }; +} + test('does not hide epics with no children when hideClosed is the only active toggle', () => { const hidden = shouldHideEpicEntry({ epicStatus: 'open', @@ -82,3 +113,50 @@ test('hides closed selected epic when hideClosed is enabled', () => { assert.equal(hidden, true); }); + +test('hideNoAcceptance=true hides task with empty acceptance_criteria', () => { + const task = makeIssue({ acceptance_criteria: '' }); + assert.equal(isTaskMatch(task, defaultFilters), false); +}); + +test('hideNoAcceptance=true hides task with whitespace-only acceptance_criteria', () => { + const task = makeIssue({ acceptance_criteria: ' \n\t ' }); + assert.equal(isTaskMatch(task, defaultFilters), false); +}); + +test('hideNoAcceptance=false shows task with empty acceptance_criteria', () => { + const task = makeIssue({ acceptance_criteria: null }); + const filters = { ...defaultFilters, hideNoAcceptance: false }; + assert.equal(isTaskMatch(task, filters), true); +}); + +test('hideNoAcceptance=true keeps epic visible regardless of acceptance_criteria', () => { + const epic = makeIssue({ issue_type: 'epic', acceptance_criteria: null }); + assert.equal(isTaskMatch(epic, defaultFilters), true); +}); + +test('hideShortDescription=true hides task with 199-character description', () => { + const task = makeIssue({ description: 'x'.repeat(199) }); + assert.equal(isTaskMatch(task, defaultFilters), false); +}); + +test('hideShortDescription=true shows task with 200-character description', () => { + const task = makeIssue({ description: 'x'.repeat(200) }); + assert.equal(isTaskMatch(task, defaultFilters), true); +}); + +test('hideShortDescription=false shows task with short description', () => { + const task = makeIssue({ description: 'short' }); + const filters = { ...defaultFilters, hideShortDescription: false, hideNoAcceptance: false }; + assert.equal(isTaskMatch(task, filters), true); +}); + +test('hideShortDescription=true keeps epic visible regardless of description length', () => { + const epic = makeIssue({ issue_type: 'epic', description: 'short' }); + assert.equal(isTaskMatch(epic, defaultFilters), true); +}); + +test('hideShortDescription treats null description as length 0', () => { + const task = makeIssue({ description: null }); + assert.equal(isTaskMatch(task, defaultFilters), false); +});