Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions openspec/changes/add-filterable-branch-picker/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Add Filterable Branch Picker

## Why

The New Task dialog picks the base branch from a native `<select>`. With many
branches the list can only be navigated by scrolling — there is no way to type
to narrow it. Users with large branch counts spend time hunting for the right
base branch on every task.

## What changes

- Replace the native `<select>` branch picker with a type-to-filter combobox:
a text input that filters the branch list by case-insensitive substring as
the user types, plus a dropdown of matches selectable by mouse or keyboard.
- Keep the committed branch as the source of truth; the picker only ever
commits a branch that exists in the repository.
- Block task submission for git projects until the branch list has loaded
and a base branch is resolved; on a failed branch fetch, show an error
with a Retry action so a task can never be created with a stale or empty
base branch.

## Impact

- New capability `branch-picker`.
- Updates `src/components/NewTaskDialog.tsx`; adds the `BranchCombobox`
component and the `branch-filter` helpers.
- No backend or IPC change — branch data still comes from the existing
`GetBranches` channel.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Branch Picker Specification

## ADDED Requirements

### Requirement: Base branch is chosen with a filterable picker

When creating a task, the New Task dialog SHALL let the user choose the base
branch with a text-filterable picker. The picker SHALL filter the available
branches by case-insensitive substring match as the user types, and SHALL only
commit a branch that exists in the repository.

#### Scenario: Typing narrows the branch list

- **WHEN** the user types text into the branch picker
- **THEN** the picker shows only branches whose name contains that text,
matched case-insensitively
- **AND** branches whose name starts with the text are listed first

#### Scenario: Selecting a branch commits it

- **WHEN** the user picks a branch from the filtered list by mouse or keyboard
- **THEN** that branch becomes the selected base branch for the task

#### Scenario: Partial text does not change the selection

- **WHEN** the user types text that does not exactly name a branch and then
moves focus away from the picker
- **THEN** the previously selected base branch remains selected

#### Scenario: Empty query shows every branch

- **WHEN** the branch picker has focus and no filter text has been entered
- **THEN** the picker lists every available branch

### Requirement: Task creation waits for a resolved base branch

The New Task dialog SHALL NOT allow a task to be created for a git project
until its base branch list has loaded and a base branch is resolved, so a task
can never be created with a stale or empty base branch.

#### Scenario: Submit is blocked while branches load

- **WHEN** the branch list for the selected git project is still loading
- **THEN** the New Task dialog prevents the task from being submitted

#### Scenario: Failed branch load offers a retry

- **WHEN** loading the branch list fails
- **THEN** the dialog shows that branches could not be loaded and offers a
Retry action
- **AND** the task cannot be submitted until the branch list loads and a base
branch is resolved
- **WHEN** the user triggers Retry
- **THEN** the dialog fetches the branch list again
10 changes: 10 additions & 0 deletions openspec/changes/add-filterable-branch-picker/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Tasks — Add Filterable Branch Picker

- [x] Add pure `filterBranches` / `matchExactBranch` helpers with unit tests.
- [x] Add a `BranchCombobox` component (type-to-filter input + dropdown,
keyboard navigation, ARIA combobox roles).
- [x] Replace the native `<select>` branch picker in the New Task dialog.
- [x] Block submission while branches load or are unresolved; show a
branch-load error with a Retry action.
- [x] Validate with `npm run check`, `npm test`, and
`openspec validate --all --strict`.
288 changes: 288 additions & 0 deletions src/components/BranchCombobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import {
createSignal,
createMemo,
createEffect,
createUniqueId,
onMount,
onCleanup,
For,
Show,
} from 'solid-js';
import { theme } from '../lib/theme';
import { clampHighlight, filterBranches, resolveOnBlur } from '../lib/branch-filter';

interface BranchComboboxProps {
/** All selectable branches. */
branches: string[];
/** Currently committed branch. */
value: string;
/** Called when the user commits a branch from the list. */
onChange: (branch: string) => void;
/** Disables the input while the branch list is loading. Defaults to false. */
loading?: boolean;
/** Optional id, used to associate an external <label>. */
id?: string;
}

/** Longest git ref names in practice; bounds per-keystroke work on paste. */
const MAX_QUERY_LENGTH = 255;

/**
* A type-to-filter branch picker. Replaces a native <select> so users with
* many branches can narrow the list by typing instead of scrolling. The
* picker only ever commits a branch that exists in `branches`.
*/
export function BranchCombobox(props: BranchComboboxProps) {
// Typed text. Only consulted for display while the dropdown is open;
// `display` falls back to the committed value when closed.
const [query, setQuery] = createSignal('');
const [open, setOpen] = createSignal(false);
const [dirty, setDirty] = createSignal(false);
const [highlight, setHighlight] = createSignal(0);
const listId = createUniqueId();
let inputRef!: HTMLInputElement;
let listRef: HTMLUListElement | undefined;
// True when the highlight last moved by keyboard — gates auto-scroll so a
// hovering mouse does not yank the list (or the dialog) under the cursor.
let keyboardNav = false;

const isLoading = () => props.loading ?? false;

// Shown text is derived, never written back: closed → the committed value,
// open/dirty → the user's typed text. Avoids a stale signal when `value`
// changes externally (e.g. a branch-list refetch on project switch).
const display = createMemo(() => (open() || dirty() ? query() : props.value));

// Once the user starts typing, filter; otherwise show every branch.
const matches = createMemo(() =>
dirty() ? filterBranches(props.branches, query()) : [...props.branches],
);

// Keep the highlighted index inside the current match list.
createEffect(() => {
const count = matches().length;
setHighlight((h) => clampHighlight(h, count));
});

// Scroll the highlighted option into view for keyboard navigation only,
// moving the listbox's own scrollTop so the outer dialog never scrolls.
createEffect(() => {
const idx = highlight();
if (!open() || !keyboardNav) return;
const list = listRef;
const node = list?.children[idx] as HTMLElement | undefined;
if (!list || !node || matches().length === 0) return;
const top = node.offsetTop;
const bottom = top + node.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) {
list.scrollTop = bottom - list.clientHeight;
}
});

function commit(branch: string): void {
props.onChange(branch);
setQuery(branch);
setDirty(false);
setOpen(false);
}

function revertToValue(): void {
setQuery(props.value);
setDirty(false);
}

function closeAndResolve(): void {
setOpen(false);
// Commit a fully-typed branch name; otherwise discard the partial text.
// When the resolved branch equals the committed value there is nothing
// to commit, so just revert the typed text — same end state, no onChange.
const resolved = resolveOnBlur(props.branches, query(), dirty(), props.value);
if (resolved !== props.value) commit(resolved);
else revertToValue();
}

// Open the list with an empty query so the first keystroke starts a fresh
// filter instead of appending to the committed branch name (typing "feature"
// must not turn "main" into "mainfeature"). Seeding empty — rather than
// selecting the seeded text — avoids relying on input.select() inside a
// focus handler, which a mouse click's caret placement on mouseup overrides.
// `dirty` stays false so the list shows every branch until the user types.
function openList(): void {
keyboardNav = true;
setQuery('');
setDirty(false);
setOpen(true);
const idx = props.branches.indexOf(props.value);
setHighlight(idx >= 0 ? idx : 0);
}

// Mouse commits keep focus on the input (the option uses mousedown +
// preventDefault), so a later focus event never fires. Reopen on click.
function onClick(): void {
if (open()) return;
openList();
}

function onInput(value: string): void {
keyboardNav = true;
setQuery(value);
setDirty(true);
setOpen(true);
setHighlight(0);
}

// Native keydown listener so Escape can stopPropagation and close only the
// dropdown, not the parent dialog (whose Escape handler is on `document`).
onMount(() => {
const el = inputRef;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
keyboardNav = true;
// Opening via arrow key seeds query/highlight the same way focus and
// click do, so the list always opens on the committed branch.
if (!open()) {
openList();
return;
}
setHighlight((h) => clampHighlight(h + 1, matches().length));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
keyboardNav = true;
if (!open()) {
openList();
return;
}
setHighlight((h) => clampHighlight(h - 1, matches().length));
} else if (e.key === 'Enter') {
// Always swallow Enter: a branch field must never submit the form.
e.preventDefault();
if (open() && matches().length > 0) commit(matches()[highlight()]);
else closeAndResolve();
} else if (e.key === 'Escape' && open()) {
e.preventDefault();
e.stopPropagation();
setOpen(false);
revertToValue();
}
};
el.addEventListener('keydown', handler);
onCleanup(() => el.removeEventListener('keydown', handler));
});

return (
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
id={props.id}
type="text"
role="combobox"
autocomplete="off"
spellcheck={false}
maxlength={MAX_QUERY_LENGTH}
aria-expanded={open()}
aria-controls={open() ? listId : undefined}
aria-autocomplete="list"
aria-activedescendant={
open() && matches().length > 0 ? `${listId}-opt-${highlight()}` : undefined
}
class="input-field"
value={display()}
placeholder={isLoading() ? 'Loading branches…' : 'Search branches…'}
disabled={isLoading()}
onInput={(e) => onInput(e.currentTarget.value)}
onFocus={openList}
onClick={onClick}
onBlur={closeAndResolve}
style={{
background: theme.bgInput,
border: `1px solid ${theme.border}`,
'border-radius': '8px',
padding: '10px 14px',
color: theme.fg,
'font-size': '14px',
'font-family': "'JetBrains Mono', monospace",
outline: 'none',
width: '100%',
'box-sizing': 'border-box',
opacity: isLoading() ? '0.5' : '1',
}}
/>
<Show when={open() && !isLoading()}>
<ul
ref={listRef}
id={listId}
role="listbox"
aria-label="Branches"
// Keep clicks on padding/scrollbar from blurring the input.
onMouseDown={(e) => e.preventDefault()}
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: '0',
right: '0',
'z-index': '30',
margin: '0',
padding: '4px',
'list-style': 'none',
'max-height': '200px',
'overflow-y': 'auto',
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
'border-radius': '8px',
'box-shadow': '0 8px 24px rgba(0,0,0,0.4)',
}}
>
<Show
when={matches().length > 0}
fallback={
<li style={{ padding: '8px 12px', color: theme.fgMuted, 'font-size': '13px' }}>
No matching branches
</li>
}
>
<For each={matches()}>
{(branch, i) => (
<li
id={`${listId}-opt-${i()}`}
role="option"
aria-selected={branch === props.value}
// mousedown (not click) fires before the input's blur, so
// the selection commits before the dropdown closes.
onMouseDown={(e) => {
e.preventDefault();
commit(branch);
}}
onMouseEnter={() => {
keyboardNav = false;
setHighlight(i());
}}
style={{
padding: '8px 12px',
'border-radius': '6px',
cursor: 'pointer',
'font-size': '13px',
'font-family': "'JetBrains Mono', monospace",
color: theme.fg,
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
background:
i() === highlight()
? theme.bgHover
: branch === props.value
? theme.bgSelectedSubtle
: 'transparent',
}}
>
{branch}
</li>
)}
</For>
</Show>
</ul>
</Show>
</div>
);
}
Loading
Loading