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
99 changes: 79 additions & 20 deletions client/src/components/cos/ReviewerPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import { normalizeReviewerSlug } from '../../lib/reviewerPins';

const normalizeReviewerValue = (value) => normalizeReviewerSlug(value);

// The Model dropdown's "type an id instead" entry. Carries a `[`/`]` pair on
// purpose: `sanitizeReviewerModelInput` strips both, so this string can never
// arrive from the free-text input and be mistaken for a stored pin — and the
// server drops any id containing them, so it could not have been persisted by an
// older build either.
const CUSTOM_MODEL_OPTION = '[custom]';

/**
* Ordered multi-reviewer picker, rendered as one row per reviewer with the five
* per-reviewer controls as columns: **Provider | Model | Effort | Optional | Max
Expand Down Expand Up @@ -96,11 +103,24 @@ export default function ReviewerPicker({
const id = useId();
const [usernameInput, setUsernameInput] = useState('');
const [usernameError, setUsernameError] = useState('');
// Reviewers whose Model cell the user switched to free text by picking the
// "Custom…" entry. Lowercased tokens, matching the case-insensitive keying the
// pin maps use. Purely presentational — nothing is stored until an id is typed,
// so this never has to round-trip through `onChange`.
const [customModelTokens, setCustomModelTokens] = useState(() => new Set());
const isCustomModel = (token) => customModelTokens.has(token.toLowerCase());
const setCustomModel = (token, on) => setCustomModelTokens((prev) => {
const next = new Set(prev);
if (on) next.add(token.toLowerCase()); else next.delete(token.toLowerCase());
return next;
});
// Render the parent's list (de-duped, order-preserving) so display === stored
// state for valid input while staying robust to malformed/legacy duplicates —
// dupes would otherwise collide on the `key={value}` below and corrupt
// reorder/remove. An empty list shows the "defaults to Copilot" hint and lets
// the user clear copilot; the server/submit layer resolves [] → ['copilot'].
// reorder/remove. An empty list shows the "follows your default AI provider"
// hint and lets the user clear the chain entirely; the server resolves [] to
// the active provider's own reviewer (falling back to copilot when that
// provider maps to none) — see `codeReviewDefaultsFromProvider`.
const selected = Array.isArray(reviewers) ? [...new Set(reviewers.map(normalizeReviewerValue))] : [];
const available = REVIEWER_OPTIONS.filter(o => !selected.includes(o.value));
const hasNonCopilot = selected.some(r => r !== 'copilot');
Expand Down Expand Up @@ -238,7 +258,11 @@ export default function ReviewerPicker({
// the value is in fact stored. `staleSuffix` names why it's absent; `setClass`
// differs only so the two columns stay visually distinguishable — passed as a
// COMPLETE class string, never interpolated, or Tailwind's scanner won't emit it.
const renderPinSelect = ({ selectId, value, options, onChange, ariaLabel, title, staleSuffix, setClass, maxWidthClass }) => (
//
// `trailingOption` appends one non-model entry after the list (the Model
// column's "Custom…" escape). It is a UI action, not a pin — the caller's
// `onChange` recognizes its sentinel value and never stores it.
const renderPinSelect = ({ selectId, value, options, onChange, ariaLabel, title, staleSuffix, setClass, maxWidthClass, trailingOption = null }) => (
<select
id={selectId}
value={value}
Expand All @@ -253,6 +277,7 @@ export default function ReviewerPicker({
<option value="">— default —</option>
{value && !options.includes(value) && <option value={value}>{value} {staleSuffix}</option>}
{options.map((option) => <option key={option} value={option}>{option}</option>)}
{trailingOption && <option value={trailingOption.value}>{trailingOption.label}</option>}
</select>
);

Expand Down Expand Up @@ -351,13 +376,25 @@ export default function ReviewerPicker({
// The Model cell. Only MODEL_SELECTABLE_REVIEWERS get one — copilot has no CLI
// and a `@username` reviewer is a person/bot, so neither takes a model.
//
// A local backend's installed-model list is authoritative (the server probed
// it), so those render a closed `<select>`. A CLI reviewer renders a text input
// + `<datalist>` instead: its catalog is a stored snapshot, and an Ollama-backed
// `claude` or a Bedrock-form id can be anything the environment provides — a
// closed list would lock out valid ids. `loaded` gates the "nothing installed"
// messaging so a pre-fetch render doesn't accuse a healthy backend of being
// empty.
// Every reviewer with a resolved catalog renders a `<select>`: the ids are
// known, and a dropdown is how the rest of this table's pins are set. What
// differs between the two reviewer kinds is the ESCAPE HATCH, not the control:
//
// - A probed local backend's installed-model list is authoritative (the server
// asked the running daemon), so its select is closed — an id it doesn't list
// isn't installed.
// - A CLI reviewer's catalog is a stored snapshot, and an Ollama-backed
// `claude` or a Bedrock-form id can be anything the environment provides, so
// its select carries a trailing "Custom…" entry that swaps the cell for a
// free-text input (with the same ids offered as a `<datalist>`). Clearing
// that input returns the cell to the dropdown. A CLI reviewer whose catalog
// resolved EMPTY (grok/kimi/opencode ship only a configured-default sentinel)
// starts in the free-text form directly — a select of nothing is a dead
// control — and so does a row already pinned to an id outside the catalog, so
// that pin stays editable rather than reading as an unpickable oddity.
//
// `loaded` gates the "nothing installed" messaging so a pre-fetch render
// doesn't accuse a healthy backend of being empty.
const renderModelCell = (token) => {
if (!MODEL_SELECTABLE_REVIEWERS.includes(token)) return renderNoPinCell(`${reviewerLabel(token)} takes no model`);
const subject = reviewerLabel(token);
Expand All @@ -367,9 +404,8 @@ export default function ReviewerPicker({
const options = modelOptions?.optionsByReviewer?.[token] || [];
const inputId = `${id}-model-${token}`;
const listId = `${id}-modellist-${token}`;
// No resolved options AND a closed picker would be a dead control, so fall
// back to free-text: better a typed id than no way to set one at all.
const freeText = modelOptions?.freeText?.[token] !== false || options.length === 0;
// Whether this reviewer may carry an id its catalog doesn't list at all.
const acceptsTypedId = modelOptions?.freeText?.[token] !== false;
// Why a local backend has no options, so the empty state says the useful
// thing instead of a bare "default" placeholder. Only meaningful once the
// probe settled — before that, an empty list is "not fetched yet", not a fact.
Expand All @@ -378,13 +414,23 @@ export default function ReviewerPicker({
? `${subject} isn't reachable — start it from Models → LLMs to list its models. You can still type an id.`
: `No ${subject} models listed — add one in Models → LLMs, or type an id.`)
: null;
// A closed select over nothing would be a dead control, so a reviewer with no
// resolved options falls back to free text whichever kind it is: better a
// typed id than no way to set one at all.
const freeText = acceptsTypedId
? (options.length === 0 || isCustomModel(token) || (Boolean(value) && !options.includes(value)))
: options.length === 0;

if (!freeText) {
return renderPinSelect({
selectId: inputId,
value: value || (defaultModel && options.includes(defaultModel) ? defaultModel : ''),
options,
onChange: (model) => setModel(token, model),
onChange: (model) => {
// The escape hatch is a UI mode, not an id — never store the sentinel.
if (model === CUSTOM_MODEL_OPTION) { setCustomModel(token, true); return; }
setModel(token, model);
},
ariaLabel: `Model for ${subject}`,
title: value
? `${subject} reviews with ${value}. Choose "default" to let it pick.`
Expand All @@ -393,27 +439,40 @@ export default function ReviewerPicker({
: `${subject} uses the model configured for its backend. Pick one to pin it for this run.`,
staleSuffix: '(not installed)',
setClass: 'text-port-accent border-port-accent/50',
maxWidthClass: 'max-w-[190px]'
maxWidthClass: 'max-w-[190px]',
// Only a reviewer that can run an id outside its catalog gets the escape.
trailingOption: acceptsTypedId ? { value: CUSTOM_MODEL_OPTION, label: 'Custom…' } : null
});
}

// "Custom…" was picked with nothing pinned yet — start empty so the field
// reads as the blank the user is about to fill, not as an id already in play.
const inputValue = value || (isCustomModel(token) ? '' : defaultModel);
return (
<>
<input
id={inputId}
type="text"
list={options.length ? listId : undefined}
value={value || defaultModel}
value={inputValue}
disabled={disabled}
maxLength={MAX_REVIEWER_MODEL_LENGTH}
placeholder={defaultModel ? `${defaultModel} (default)` : 'default'}
onChange={(e) => setModel(token, e.target.value)}
// Leaving the field with nothing pinned is how the user backs out of
// Custom…: with no id to keep, the catalog dropdown is the more useful
// control. Deliberately on blur rather than on an empty onChange —
// clearing the field to retype an id would otherwise swap the control
// out from under the cursor mid-edit.
onBlur={() => { if (!value && options.length) setCustomModel(token, false); }}
aria-label={`Model for ${subject}`}
title={value
? `${subject} reviews with ${value}. Clear to let it use its own default.`
: (defaultModel
? `${subject} uses ${defaultModel} by default. Type or pick another id to pin one.`
: (emptyHint || `${subject} uses its own default model. Type or pick an id to pin one.`))}
: (options.length
? `Type an id ${subject} accepts. Leave it empty to go back to its listed models.`
: (defaultModel
? `${subject} uses ${defaultModel} by default. Type an id to pin one.`
: (emptyHint || `${subject} uses its own default model. Type an id to pin one.`)))}
className={`w-full min-w-0 max-w-[190px] px-1.5 py-0.5 text-[11px] font-mono rounded border bg-port-bg min-h-[28px] disabled:opacity-40 focus:outline-none focus:border-port-accent ${value
? 'text-port-accent border-port-accent/50'
: 'text-gray-500 border-port-border/60'}`}
Expand Down Expand Up @@ -558,7 +617,7 @@ export default function ReviewerPicker({
</>
)}
{selected.length === 0 && (
<span className="text-xs text-gray-600 italic">none — defaults to Copilot</span>
<span className="text-xs text-gray-600 italic">none — follows your default AI provider</span>
)}
</div>

Expand Down
48 changes: 45 additions & 3 deletions client/src/components/cos/ReviewerPicker.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('ReviewerPicker', () => {

it('shows the empty-state hint when no reviewers are selected', () => {
render(<ReviewerPicker reviewers={[]} onChange={() => {}} />);
expect(screen.getByText(/none — defaults to Copilot/)).toBeInTheDocument();
expect(screen.getByText(/none — follows your default AI provider/)).toBeInTheDocument();
});

it('de-dupes a malformed list with duplicates (order-preserving)', () => {
Expand Down Expand Up @@ -308,11 +308,50 @@ describe('ReviewerPicker', () => {
expect(screen.getByRole('option', { name: 'qwen2.5-coder:32b' })).toBeInTheDocument();
});

it('renders a CLI reviewer as a free-text input so an env-specific id can be typed', () => {
it('renders a CLI reviewer as a dropdown of its catalog', () => {
render(<ReviewerPicker reviewers={['claude']} modelOptions={modelOptions} onChange={() => {}} />);
expect(screen.getByLabelText('Model for Claude').tagName).toBe('SELECT');
expect(screen.getByRole('option', { name: 'claude-tier-a' })).toBeInTheDocument();
});

it('offers a CLI reviewer a Custom… escape that swaps in a free-text input', () => {
const onChange = vi.fn();
render(<ReviewerPicker reviewers={['claude']} modelOptions={modelOptions} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: '[custom]' } });
// An Ollama-backed / Bedrock-form claude id can't be enumerated, so the
// control must accept a typed value rather than only a pick.
// escape must accept a typed value rather than only a pick.
expect(screen.getByLabelText('Model for Claude').tagName).toBe('INPUT');
// The sentinel is a UI mode, not an id — it must never be stored as a pin.
expect(onChange).not.toHaveBeenCalled();
});

it('does not offer the Custom… escape to a probed local backend', () => {
render(<ReviewerPicker reviewers={['ollama']} modelOptions={modelOptions} onChange={() => {}} />);
// Ollama's list is the daemon's own answer: an id it doesn't list isn't installed.
expect(screen.queryByRole('option', { name: 'Custom…' })).not.toBeInTheDocument();
});

it('leaving an empty Custom… field returns the cell to the dropdown', () => {
render(<ReviewerPicker reviewers={['claude']} modelOptions={modelOptions} onChange={() => {}} />);
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: '[custom]' } });
fireEvent.blur(screen.getByLabelText('Model for Claude'));
expect(screen.getByLabelText('Model for Claude').tagName).toBe('SELECT');
});

it('keeps the Custom… input mounted while a typed id is being edited', () => {
render(<ReviewerPicker reviewers={['claude']} modelOptions={modelOptions} onChange={() => {}} />);
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: '[custom]' } });
// Clearing the field to retype must not swap the control out mid-edit.
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: '' } });
expect(screen.getByLabelText('Model for Claude').tagName).toBe('INPUT');
});

it('keeps a pin outside the catalog editable rather than unpickable', () => {
render(<ReviewerPicker reviewers={['claude']} reviewerModels={{ claude: 'llama3.1:70b' }} modelOptions={modelOptions} onChange={() => {}} />);
const control = screen.getByLabelText('Model for Claude');
expect(control.tagName).toBe('INPUT');
expect(control).toHaveValue('llama3.1:70b');
});

it('falls back to free-text when no options resolved (a closed empty select would be dead)', () => {
Expand Down Expand Up @@ -358,6 +397,7 @@ describe('ReviewerPicker', () => {
it('treats a whitespace-only entry as a clear, not a pin', () => {
const onChange = vi.fn();
render(<ReviewerPicker reviewers={['codex']} reviewerModels={{ codex: 'gpt-tier-a' }} modelOptions={modelOptions} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Model for Codex'), { target: { value: '[custom]' } });
fireEvent.change(screen.getByLabelText('Model for Codex'), { target: { value: ' ' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewerModels: {} }));
});
Expand Down Expand Up @@ -417,13 +457,15 @@ describe('ReviewerPicker', () => {
// `foo]~opt` would close the selector early and leave slashdo reading the
// rest as a suffix; the server drops such an id, so accepting it here would
// show a pin that never persists.
fireEvent.change(screen.getByLabelText('Model for Codex'), { target: { value: '[custom]' } });
fireEvent.change(screen.getByLabelText('Model for Codex'), { target: { value: 'foo]~opt' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewerModels: { codex: 'foo~opt' } }));
});

it('keeps a space in a typed id (slashdo selectors are free-form)', () => {
const onChange = vi.fn();
render(<ReviewerPicker reviewers={['claude']} modelOptions={modelOptions} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: '[custom]' } });
fireEvent.change(screen.getByLabelText('Model for Claude'), { target: { value: 'Some Model (High)' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewerModels: { claude: 'Some Model (High)' } }));
});
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/settings/CodeReviewersTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default function CodeReviewersTab() {
<h2 className="text-base font-semibold text-white">Code Review Defaults</h2>
</div>
<p className="text-xs text-gray-500">
Default Review Loop reviewer chain — used by ad-hoc CoS tasks and task-type schedules that haven't pinned their own. Local-LLM reviewers route the diff through PortOS's local code-review endpoint; the {CLI_REVIEWER_LIST} reviewers invoke their CLI directly. Each runs the model pinned on its row (Claude also supports an Ollama-backed CLI for local-only setups — type one of your installed Ollama models).
Default Review Loop reviewer chain — used by ad-hoc CoS tasks and task-type schedules that haven't pinned their own. Leave it empty and reviews follow your default AI provider, at its own model and reasoning effort. Local-LLM reviewers route the diff through PortOS's local code-review endpoint; the {CLI_REVIEWER_LIST} reviewers invoke their CLI directly. Each runs the model picked on its row — choose <span className="font-mono">Custom…</span> to type an id its catalog doesn't list, such as an installed Ollama model for an Ollama-backed Claude.
</p>

{loadError && (
Expand Down
8 changes: 5 additions & 3 deletions client/src/hooks/useReviewerModelOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ const PROBED_LOCAL_BACKENDS = LOCAL_LLM_BACKENDS.map((b) => b.id);
* the installed Ollama ids (an Ollama-backed `claude` CLI, where `--model` selects
* the local model). Deduped, order-preserving.
*
* `freeText` marks a reviewer whose picker must accept a typed id, not just a
* pick: an Ollama-backed `claude` can run any locally-installed id, and a
* `freeText` marks a reviewer whose picker must ALSO accept a typed id, not only
* a pick: an Ollama-backed `claude` can run any locally-installed id, and a
* Bedrock/Vertex install needs its environment's own id form, neither of which a
* catalog can enumerate. Consumers render a `<datalist>` for those.
* catalog can enumerate. Those still render a dropdown of the catalog — the flag
* adds a "Custom…" escape to it (see `ReviewerPicker`'s Model cell); a reviewer
* marked `false` gets a closed list, because its options came from a live probe.
*
* `unavailable` distinguishes "backend is down" from "backend has no models" so
* the empty state can say the useful thing. Absent = not probed (every reviewer
Expand Down
Loading