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
4 changes: 1 addition & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public
- MCP / AGENTS docs no longer imply a silent `PRM_PASSWORD=workbench` default
- Settings → AI: OpenAI-compatible gateways are a separate provider from Cursor Cloud Agents
- Empty workspaces land in setup until finished or skipped; demo seed marks onboarding complete
<<<<<<< HEAD
- **Cursor Cloud Agents** chat: stream status and assistant text live via SSE; empty-result errors include agent/run ids and a dashboard link
=======
- Primary CTA hierarchy: NextStep owns the solid primary action; PageHeader/Section duplicates and table-row moves use secondary/ghost so pages like Cycles no longer show the same green button 3–4 times
>>>>>>> bd04ca1 (Clarify primary CTAs across Cycles and other pages)
- Form dropdowns use themed **react-select** (`AppSelect`) with a properly padded chevron; native `<select>` keeps a CSS fallback chevron that no longer hugs the right edge

### Fixed

Expand Down
3 changes: 2 additions & 1 deletion apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@prm/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.2"
"react-router-dom": "^7.18.2",
"react-select": "^5.10.2"
},
"devDependencies": {
"@types/react": "^19.2.18",
Expand Down
107 changes: 107 additions & 0 deletions apps/ui/src/components/AppSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Select, {
type ClassNamesConfig,
type GroupBase,
type Props as ReactSelectProps,
type SingleValue,
} from "react-select";

export type AppSelectOption = {
value: string;
label: string;
isDisabled?: boolean;
};

type AppSelectProps = {
id?: string;
value: string;
options: AppSelectOption[];
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
/** Prefer for unlabeled selects (e.g. compact rows). */
"aria-label"?: string;
className?: string;
isClearable?: boolean;
isSearchable?: boolean;
/** Stretch to field width (default) vs hug content in toolbars. */
fullWidth?: boolean;
};

const classNames: ClassNamesConfig<AppSelectOption, false, GroupBase<AppSelectOption>> = {
container: () => "app-select__container",
control: (state) =>
[
"app-select__control",
state.isFocused ? "app-select__control--focused" : "",
state.isDisabled ? "app-select__control--disabled" : "",
]
.filter(Boolean)
.join(" "),
valueContainer: () => "app-select__value",
placeholder: () => "app-select__placeholder",
singleValue: () => "app-select__single",
input: () => "app-select__input",
indicatorsContainer: () => "app-select__indicators",
dropdownIndicator: (state) =>
`app-select__dropdown${state.selectProps.menuIsOpen ? " app-select__dropdown--open" : ""}`,
clearIndicator: () => "app-select__clear",
indicatorSeparator: () => "app-select__separator",
menu: () => "app-select__menu",
menuList: () => "app-select__menu-list",
option: (state) =>
[
"app-select__option",
state.isFocused ? "app-select__option--focused" : "",
state.isSelected ? "app-select__option--selected" : "",
state.isDisabled ? "app-select__option--disabled" : "",
]
.filter(Boolean)
.join(" "),
noOptionsMessage: () => "app-select__empty",
};

/**
* Themed single-select built on react-select.
* Prefer this over native `<select>` so the chevron/menu match PRM chrome.
*/
export function AppSelect({
id,
value,
options,
onChange,
placeholder = "Select…",
disabled = false,
"aria-label": ariaLabel,
className = "",
isClearable = false,
isSearchable = true,
fullWidth = true,
}: AppSelectProps) {
const selected = options.find((o) => o.value === value) ?? null;

const handleChange = (next: SingleValue<AppSelectOption>) => {
onChange(next?.value ?? "");
};

const selectProps: ReactSelectProps<AppSelectOption, false> = {
inputId: id,
instanceId: id,
options,
value: selected,
onChange: handleChange,
placeholder,
isDisabled: disabled,
isClearable,
isSearchable,
classNames,
unstyled: true,
"aria-label": ariaLabel,
className: ["app-select", fullWidth ? "app-select--full" : "app-select--auto", className]
.filter(Boolean)
.join(" "),
menuPortalTarget: typeof document !== "undefined" ? document.body : null,
menuPosition: "fixed",
};

return <Select {...selectProps} />;
}
19 changes: 11 additions & 8 deletions apps/ui/src/components/RetentionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import type { WorkspaceSettingsDTO } from "@prm/shared";
import { api } from "../lib/api";
import { Section } from "./PageChrome";
import { AppSelect } from "./AppSelect";

export function RetentionPanel() {
const [settings, setSettings] = useState<WorkspaceSettingsDTO | null>(null);
Expand Down Expand Up @@ -59,20 +60,22 @@ export function RetentionPanel() {
</p>
<div className="field">
<label htmlFor="retention-select">Retention policy</label>
<select
<AppSelect
id="retention-select"
value={settings.retention}
onChange={(e) =>
onChange={(v) =>
setSettings({
...settings,
retention: e.target.value as WorkspaceSettingsDTO["retention"],
retention: v as WorkspaceSettingsDTO["retention"],
})
}
>
<option value="current_plus_previous">Current + previous cycle</option>
<option value="current_only">Current cycle only</option>
<option value="keep_all">Keep all cycles</option>
</select>
isSearchable={false}
options={[
{ value: "current_plus_previous", label: "Current + previous cycle" },
{ value: "current_only", label: "Current cycle only" },
{ value: "keep_all", label: "Keep all cycles" },
]}
/>
</div>
<div className="field">
<label>
Expand Down
46 changes: 23 additions & 23 deletions apps/ui/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PersonDTO } from "@prm/shared";
import { api, streamChat } from "../lib/api";
import { estimateThenConfirm } from "../lib/aiEstimate";
import { NextStep, PageHeader } from "../components/PageChrome";
import { AppSelect } from "../components/AppSelect";

type CycleRow = {
id: string;
Expand Down Expand Up @@ -289,42 +290,41 @@ export function ChatPage() {
<div className="row chat-scope-row">
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
<label htmlFor="chat-person">Person scope</label>
<select
<AppSelect
id="chat-person"
value={personId}
onChange={(e) => {
setPersonId(e.target.value);
onChange={(v) => {
setPersonId(v);
clearChat();
}}
disabled={sending}
>
<option value="">Entire workspace</option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.title ? ` — ${p.title}` : ""}
</option>
))}
</select>
options={[
{ value: "", label: "Entire workspace" },
...people.map((p) => ({
value: p.id,
label: p.title ? `${p.name} — ${p.title}` : p.name,
})),
]}
/>
</div>
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
<label htmlFor="chat-cycle">Cycle (optional)</label>
<select
<AppSelect
id="chat-cycle"
value={cycleId}
onChange={(e) => {
setCycleId(e.target.value);
onChange={(v) => {
setCycleId(v);
clearChat();
}}
disabled={sending}
>
<option value="">No cycle filter</option>
{cycles.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.status})
</option>
))}
</select>
options={[
{ value: "", label: "No cycle filter" },
...cycles.map((c) => ({
value: c.id,
label: `${c.name} (${c.status})`,
})),
]}
/>
</div>
</div>
</div>
Expand Down
40 changes: 20 additions & 20 deletions apps/ui/src/pages/ConsistencyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { api } from "../lib/api";
import { downloadBinaryExport, openHtmlExport } from "../lib/exports";
import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
import { AppSelect } from "../components/AppSelect";

export function ConsistencyPage() {
const [params, setParams] = useSearchParams();
Expand Down Expand Up @@ -81,17 +82,15 @@ export function ConsistencyPage() {
<div className="panel">
<div className="field" style={{ marginBottom: 0, maxWidth: 360 }}>
<label htmlFor="consistency-cycle">Cycle</label>
<select
<AppSelect
id="consistency-cycle"
value={cycleId || data?.cycleId || ""}
onChange={(e) => setParams({ cycleId: e.target.value })}
>
{cycles.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.status})
</option>
))}
</select>
onChange={(v) => setParams({ cycleId: v })}
options={cycles.map((c) => ({
value: c.id,
label: `${c.name} (${c.status})`,
}))}
/>
</div>
</div>

Expand Down Expand Up @@ -227,22 +226,23 @@ export function ConsistencyPage() {
</div>
<div className="field" style={{ margin: 0 }}>
<label>Overall</label>
<select
<AppSelect
value={row.overall}
onChange={(e) =>
onChange={(v) =>
setDirectorDecisions({
...directorDecisions,
[card.personId]: { ...row, overall: e.target.value },
[card.personId]: { ...row, overall: v },
})
}
>
<option value="">Keep</option>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={String(n)}>
{n}
</option>
))}
</select>
isSearchable={false}
options={[
{ value: "", label: "Keep" },
...[1, 2, 3, 4, 5].map((n) => ({
value: String(n),
label: String(n),
})),
]}
/>
</div>
<div className="field" style={{ margin: 0, flex: 1, minWidth: 160 }}>
<label>Note</label>
Expand Down
39 changes: 19 additions & 20 deletions apps/ui/src/pages/CyclesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CycleDTO, PersonDTO } from "@prm/shared";
import { api } from "../lib/api";
import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
import { AppSelect } from "../components/AppSelect";

const PHASE_STATUSES = [
{ value: "self_open", label: "Self open" },
Expand Down Expand Up @@ -319,33 +320,31 @@ export function CyclesPage() {
>
<div className="field">
<label>Cycle</label>
<select value={selectedCycle} onChange={(e) => setSelectedCycle(e.target.value)}>
{cycles.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<AppSelect
value={selectedCycle}
onChange={(v) => setSelectedCycle(v)}
options={cycles.map((c) => ({ value: c.id, label: c.name }))}
/>
</div>
<div className="field">
<label>Subject</label>
<select value={subjectId} onChange={(e) => setSubjectId(e.target.value)}>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<AppSelect
value={subjectId}
onChange={(v) => setSubjectId(v)}
options={people.map((p) => ({ value: p.id, label: p.name }))}
/>
</div>
<div className="field">
<label>Kind</label>
<select
<AppSelect
value={bundleKind}
onChange={(e) => setBundleKind(e.target.value as "self" | "peer")}
>
<option value="self">Self-review</option>
<option value="peer">Peer feedback</option>
</select>
onChange={(v) => setBundleKind(v as "self" | "peer")}
isSearchable={false}
options={[
{ value: "self", label: "Self-review" },
{ value: "peer", label: "Peer feedback" },
]}
/>
</div>
{bundleKind === "peer" && (
<div className="field">
Expand Down
Loading
Loading