[beadboard] Add quality filters for acceptance criteria and description length

## 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) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-18 14:00:51 +00:00
parent 9993738b29
commit 845e90d7c0
8 changed files with 152 additions and 4 deletions

View file

@ -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
</button>
<label className="flex items-center gap-2 text-[10px] font-medium text-[var(--text-tertiary)] cursor-pointer">
<input
type="checkbox"
checked={filters.hideNoAcceptance}
onChange={(event) => onFiltersChange({ ...filters, hideNoAcceptance: event.target.checked })}
className="h-3 w-3 rounded border-[var(--border-subtle)] bg-[var(--surface-quaternary)]"
aria-label="Hide tasks without acceptance criteria"
/>
<span>Hide tasks without acceptance criteria</span>
</label>
<label className="flex items-center gap-2 text-[10px] font-medium text-[var(--text-tertiary)] cursor-pointer">
<input
type="checkbox"
checked={filters.hideShortDescription}
onChange={(event) => onFiltersChange({ ...filters, hideShortDescription: event.target.checked })}
className="h-3 w-3 rounded border-[var(--border-subtle)] bg-[var(--surface-quaternary)]"
aria-label="Hide tasks with short description"
/>
<span>Hide tasks with short description (&lt;200 chars)</span>
</label>
</div>
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">

View file

@ -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
</button>
<label className="flex items-center gap-2 text-[10px] font-medium text-[var(--text-tertiary)] cursor-pointer">
<input
type="checkbox"
checked={filters.hideNoAcceptance}
onChange={(event) => onFiltersChange({ ...filters, hideNoAcceptance: event.target.checked })}
className="h-3 w-3 rounded border-[var(--border-subtle)] bg-[var(--surface-quaternary)]"
aria-label="Hide tasks without acceptance criteria"
/>
<span>Hide tasks without acceptance criteria</span>
</label>
<label className="flex items-center gap-2 text-[10px] font-medium text-[var(--text-tertiary)] cursor-pointer">
<input
type="checkbox"
checked={filters.hideShortDescription}
onChange={(event) => onFiltersChange({ ...filters, hideShortDescription: event.target.checked })}
className="h-3 w-3 rounded border-[var(--border-subtle)] bg-[var(--surface-quaternary)]"
aria-label="Hide tasks with short description"
/>
<span>Hide tasks with short description (&lt;200 chars)</span>
</label>
</div>
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">

View file

@ -67,6 +67,8 @@ export function UnifiedShell({
priority: 'all',
preset: 'all',
hideClosed: true,
hideNoAcceptance: true,
hideShortDescription: true,
});
const [actor, setActor] = useState<string>('');

View file

@ -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;

View file

@ -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<string, unknown>) : {},
};
}

View file

@ -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 ?? {},
};
}

View file

@ -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<string, unknown>;
}

View file

@ -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> = {}): 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);
});