[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:
parent
9993738b29
commit
845e90d7c0
8 changed files with 152 additions and 4 deletions
|
|
@ -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 (<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)]">
|
||||
|
|
|
|||
|
|
@ -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 (<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)]">
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ export function UnifiedShell({
|
|||
priority: 'all',
|
||||
preset: 'all',
|
||||
hideClosed: true,
|
||||
hideNoAcceptance: true,
|
||||
hideShortDescription: true,
|
||||
});
|
||||
|
||||
const [actor, setActor] = useState<string>('');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue