refactor(ui): unify shared primitives across pages

This commit is contained in:
thibaud-lclr 2026-04-20 08:55:26 +02:00
parent 8536dedc9e
commit 62b381b844
20 changed files with 483 additions and 258 deletions

View file

@ -0,0 +1,87 @@
# Audit UI/UX transverse - 2026-04-20
## Périmètre audité
Front React complet sous `src/components`:
- `agents/*`
- `projects/*`
- `tickets/*`
- `trackers/*`
- `settings/*`
- `layout/*`
## Incohérences détectées (avant refactor)
1. Boutons d'action dupliqués avec variations de couleurs et de tailles selon les pages (`bg-blue-600`, `bg-gray-900`, `bg-red-100`, etc.).
2. Champs de formulaire répétés avec plusieurs variantes de classes (`input`, `select`, `textarea`) non centralisées.
3. Cartes/blocs de contenu dupliqués (`rounded-lg border border-gray-200 bg-white p-4`) avec petites divergences de spacing.
4. Alertes (`error`, `warning`, `success`, `info`) construites page par page avec des styles proches mais non unifiés.
5. Badges de statut ticket réimplémentés dans plusieurs pages avec logique dupliquée et couverture de statuts non homogène.
## Composants réutilisés sur plusieurs pages
### 1) Boutons (multi-pages)
Usage sur:
- Agents: `AgentList`, `AgentForm`
- Projets: `ProjectForm`, `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`
- Tickets: `TicketList`, `TicketDetail`
- Trackers: `TrackerConfig`, `TrackerList`, `FilterBuilder`
- Settings: `SettingsPage`
- Layout: `WindowControls`, `NotificationCenter`
- UI: `ConfirmModal`
Unification appliquée:
- Ajout de `buttonClass()` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Variantes centralisées: `primary`, `secondary`, `danger`, `dangerSoft`, `neutralDark`, `success`, `ghost`
- Tailles centralisées: `xs`, `sm`, `md`, `icon`
### 2) Formulaires (input/select/textarea + labels)
Usage sur:
- `AgentForm`, `ProjectForm`, `SettingsPage`, `TrackerConfig`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectTasks`, `TicketDetail`, `FilterBuilder`
Unification appliquée:
- `labelClass`, `inputClass`, `textAreaClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Remplacement des classes inline répétées par ces styles partagés.
### 3) Cartes de contenu
Usage sur:
- `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `TrackerConfig`, `TrackerList`, `TicketList`, `TicketDetail`, `AgentList`
Unification appliquée:
- `cardClass` / `cardContentClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Réemploi des mêmes classes de surface sur les pages.
### 4) Alertes (erreur/succès/warning/info)
Usage sur:
- `AgentForm`, `AgentList`, `ProjectForm`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `SettingsPage`, `TrackerConfig`, `TicketDetail`
Unification appliquée:
- `noticeClass(tone, compact)` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Tons standards: `error`, `success`, `warning`, `info`.
### 5) Badges de statut ticket
Usage sur:
- `TicketList`, `TicketDetail`, `ProjectDashboard`
Unification appliquée:
- Nouveau composant [`src/components/ui/TicketStatusBadge.tsx`](/home/leclere/Projets/IA/orchai/src/components/ui/TicketStatusBadge.tsx)
- Mapping de statuts mutualisé via `ticketStatusClass()`
- Suppression des mappings locaux dupliqués dans les pages tickets/dashboard.
## Méthode de génération commune
Oui, pour les composants réutilisés multi-pages identifiés, la génération du style passe désormais par une couche commune:
- `primitives.ts` pour les patterns UI transverses
- `TicketStatusBadge.tsx` pour les badges de statuts ticket
Cela réduit la divergence visuelle et simplifie les évolutions de design futures.
## Limites / reste à harmoniser
1. Certaines zones très contextuelles (ex: bulles de conversation live, badges de session archivée, blocs de diff) conservent des styles spécifiques, volontairement.
2. Une seconde passe peut encore factoriser quelques micro-patterns (ex: tabs, badges non-ticket, liens d'action inline) si nécessaire.

View file

@ -3,6 +3,14 @@ import { useNavigate, useParams } from "react-router-dom";
import { createAgent, getAgent, improveAgentPrompt, updateAgent } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { AgentRole, AgentTool } from "../../lib/types";
import {
buttonClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
textAreaClass,
} from "../ui/primitives";
export default function AgentForm() {
const navigate = useNavigate();
@ -76,36 +84,36 @@ export default function AgentForm() {
return (
<div className="mx-auto max-w-2xl p-8">
<h2 className="mb-6 text-xl font-bold">{isEditing ? "Edit agent" : "New agent"}</h2>
<h2 className={`mb-6 ${pageTitleClass}`}>{isEditing ? "Edit agent" : "New agent"}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
{isEditing && isDefaultAgent && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
<div className={noticeClass("info")}>
This is a default agent. Its tool and script/prompt can be modified, but its name and
role are fixed.
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
<label className={labelClass}>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isEditing && isDefaultAgent}
required
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
<label className={labelClass}>Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as AgentRole)}
disabled={isEditing && isDefaultAgent}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
<option value="analyst">Analyst</option>
<option value="developer">Developer</option>
@ -113,11 +121,11 @@ export default function AgentForm() {
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Tool</label>
<label className={labelClass}>Tool</label>
<select
value={tool}
onChange={(e) => setTool(e.target.value as AgentTool)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
<option value="codex">Codex</option>
<option value="claude_code">Claude Code</option>
@ -126,14 +134,14 @@ export default function AgentForm() {
<div>
<div className="mb-1 flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-gray-700">
<label className={labelClass}>
Script / custom prompt (appended to built-in prompt)
</label>
<button
type="button"
onClick={() => void handleImprovePrompt()}
disabled={loading || initializing || improvingPrompt}
className="rounded border border-emerald-300 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50"
className={buttonClass({ variant: "success", size: "xs" })}
>
{improvingPrompt ? "Amélioration..." : "Amélioration"}
</button>
@ -143,7 +151,7 @@ export default function AgentForm() {
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
disabled={loading || initializing || improvingPrompt}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
className={`${textAreaClass} disabled:bg-gray-50`}
placeholder="Extra instructions for this agent..."
/>
<p className="mt-1 text-xs text-gray-500">
@ -152,7 +160,7 @@ export default function AgentForm() {
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
@ -161,14 +169,14 @@ export default function AgentForm() {
<button
type="submit"
disabled={loading || initializing || improvingPrompt}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
className={buttonClass({ variant: "secondary" })}
>
Cancel
</button>

View file

@ -4,6 +4,13 @@ import { deleteAgent, listAgents } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { Agent } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
import {
buttonClass,
cardContentClass,
noticeClass,
pageClass,
pageTitleClass,
} from "../ui/primitives";
export default function AgentList() {
const [agents, setAgents] = useState<Agent[]>([]);
@ -52,13 +59,10 @@ export default function AgentList() {
}
return (
<div className="p-8">
<div className={pageClass}>
<div className="mb-6 flex items-center justify-between gap-3">
<h2 className="text-xl font-bold">Agents</h2>
<Link
to="/agents/new"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
<h2 className={pageTitleClass}>Agents</h2>
<Link to="/agents/new" className={buttonClass({ variant: "primary" })}>
New agent
</Link>
</div>
@ -66,7 +70,7 @@ export default function AgentList() {
{loading && <div className="text-sm text-gray-400">Loading...</div>}
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
<div className={`mb-4 ${noticeClass("error", true)}`}>
{error}
</div>
)}
@ -79,7 +83,7 @@ export default function AgentList() {
{agents.map((agent) => (
<div
key={agent.id}
className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100"
className={`group relative transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100 ${cardContentClass}`}
>
<Link
to={`/agents/${agent.id}/edit`}
@ -117,7 +121,7 @@ export default function AgentList() {
<button
type="button"
onClick={() => setAgentToDelete(agent)}
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
>
Delete
</button>

View file

@ -12,6 +12,7 @@ import {
markNotificationRead,
} from "../../lib/api";
import type { OrchaiNotification } from "../../lib/types";
import { buttonClass, cardClass, pillClass } from "../ui/primitives";
type NewNotificationEvent = {
notification: OrchaiNotification;
@ -180,7 +181,7 @@ export default function NotificationCenter() {
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
className={`${buttonClass({ variant: "secondary", size: "sm" })} relative border border-gray-300`}
>
Notifications
{unreadCount > 0 && (
@ -191,13 +192,13 @@ export default function NotificationCenter() {
</button>
{open && (
<div className="absolute right-0 z-20 mt-2 w-[360px] rounded-lg border border-gray-200 bg-white shadow-lg">
<div className={`absolute right-0 z-20 mt-2 w-[360px] shadow-lg ${cardClass}`}>
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<h3 className="text-sm font-semibold text-gray-800">Notifications</h3>
<button
type="button"
onClick={handleMarkAllRead}
className="text-xs text-blue-600 hover:underline"
className={buttonClass({ variant: "ghost", size: "xs" })}
disabled={!projectId || unreadCount === 0}
>
Mark all read
@ -217,11 +218,7 @@ export default function NotificationCenter() {
onClick={() =>
setFilter(item.id as "all" | "unread" | "errors" | "fixes")
}
className={`rounded px-2 py-1 text-xs ${
filter === item.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
className={`${pillClass(filter === item.id)} text-xs`}
>
{item.label}
</button>

View file

@ -1,4 +1,5 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { buttonClass } from "../ui/primitives";
async function minimizeWindow() {
try {
@ -31,7 +32,7 @@ export default function WindowControls() {
type="button"
onClick={() => void minimizeWindow()}
title="Minimize"
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100"
className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
>
_
</button>
@ -39,7 +40,7 @@ export default function WindowControls() {
type="button"
onClick={() => void toggleMaximizeWindow()}
title="Maximize / Restore"
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100"
className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
>
[]
</button>
@ -47,7 +48,7 @@ export default function WindowControls() {
type="button"
onClick={() => void closeWindow()}
title="Close"
className="h-7 w-7 rounded border border-red-300 bg-red-50 text-xs text-red-700 hover:bg-red-100"
className={`${buttonClass({ variant: "dangerSoft", size: "icon" })} border border-red-300`}
>
X
</button>

View file

@ -16,6 +16,8 @@ import type {
} from "../../lib/types";
import TrackerList from "../trackers/TrackerList";
import ConfirmModal from "../ui/ConfirmModal";
import TicketStatusBadge from "../ui/TicketStatusBadge";
import { buttonClass, cardContentClass, pageClass, pageTitleClass } from "../ui/primitives";
type ActivityLevel = "info" | "success" | "error";
@ -331,19 +333,6 @@ export default function ProjectDashboard() {
navigate("/");
}
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
default:
return "bg-blue-100 text-blue-700";
}
}
function formatLeadTime(seconds: number | null): string {
if (seconds === null || Number.isNaN(seconds)) {
return "—";
@ -374,26 +363,26 @@ export default function ProjectDashboard() {
const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—";
return (
<div className="p-8">
<div className={pageClass}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">{project.name}</h2>
<h2 className={pageTitleClass}>{project.name}</h2>
<div className="flex gap-2">
<Link
to={`/projects/${project.id}/edit`}
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
className={buttonClass({ variant: "secondary", size: "sm" })}
>
Edit
</Link>
<button
onClick={() => setIsDeleteModalOpen(true)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
className={buttonClass({ variant: "dangerSoft", size: "sm" })}
>
Delete
</button>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<div className={`${cardContentClass} space-y-3`}>
<div>
<span className="text-sm text-gray-500">Path:</span>
<span className="ml-2 text-sm font-mono">{project.path}</span>
@ -499,7 +488,7 @@ export default function ProjectDashboard() {
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Live Activity</h3>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className={`${cardContentClass} space-y-4`}>
<div className="flex flex-wrap gap-3">
<div className="text-sm rounded-full bg-blue-50 text-blue-700 px-3 py-1">
Polling en cours: {activePollList.length}
@ -566,7 +555,7 @@ export default function ProjectDashboard() {
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
className={`${cardContentClass} flex items-center justify-between gap-4`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@ -574,11 +563,7 @@ export default function ProjectDashboard() {
<span className="text-sm font-medium truncate">{ticket.artifact_title}</span>
</div>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${statusBadgeClass(ticket.status)}`}
>
{ticket.status}
</span>
<TicketStatusBadge status={ticket.status} className="shrink-0" />
</Link>
))}
</div>

View file

@ -3,6 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, getProject, updateProject } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import {
buttonClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
export default function ProjectForm() {
const navigate = useNavigate();
@ -60,13 +67,13 @@ export default function ProjectForm() {
return (
<div className="max-w-lg mx-auto p-8">
<h2 className="text-xl font-bold mb-6">
<h2 className={`${pageTitleClass} mb-6`}>
{isEditing ? "Edit project" : "New project"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Project name
</label>
<input
@ -74,14 +81,14 @@ export default function ProjectForm() {
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
{!isEditing && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Source
</label>
<div className="flex gap-4">
@ -105,7 +112,7 @@ export default function ProjectForm() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
{mode === "local" ? "Folder path" : "Git URL"}
</label>
<div className="flex gap-2">
@ -119,13 +126,13 @@ export default function ProjectForm() {
? "/home/user/code/myproject"
: "https://github.com/org/repo.git"
}
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={`flex-1 ${inputClass}`}
/>
{mode === "local" && (
<button
type="button"
onClick={handleBrowse}
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
className={buttonClass({ variant: "secondary" })}
>
Browse
</button>
@ -136,7 +143,7 @@ export default function ProjectForm() {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Base branch
</label>
<input
@ -144,12 +151,12 @@ export default function ProjectForm() {
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
@ -158,14 +165,14 @@ export default function ProjectForm() {
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
className={buttonClass({ variant: "secondary" })}
>
Cancel
</button>

View file

@ -16,6 +16,15 @@ import type {
GraylogDetection,
GraylogSubject,
} from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
} from "../ui/primitives";
export default function ProjectGraylog() {
const { projectId } = useParams<{ projectId: string }>();
@ -146,35 +155,32 @@ export default function ProjectGraylog() {
}
return (
<div className="space-y-6 p-8">
<div className={pageStackClass}>
<div>
{projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
<Link to={`/projects/${projectId}`} className={backLinkClass}>
Back
</Link>
)}
<h2 className="text-xl font-bold">Graylog</h2>
<h2 className={pageTitleClass}>Graylog</h2>
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className={noticeClass("error")}>
{error}
</div>
)}
{success && (
<div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
<div className={noticeClass("success")}>
{success}
</div>
)}
<form
onSubmit={handleSave}
className="space-y-3 rounded-lg border border-gray-200 bg-white p-4"
>
<form onSubmit={handleSave} className={`space-y-3 ${cardContentClass}`}>
<h3 className="text-sm font-semibold text-gray-900">Configuration</h3>
<input
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://graylog.example.com"
@ -182,7 +188,7 @@ export default function ProjectGraylog() {
/>
<input
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={apiToken}
onChange={(event) => setApiToken(event.target.value)}
placeholder={
@ -195,7 +201,7 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-2">
<select
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={analystAgentId}
onChange={(event) => setAnalystAgentId(event.target.value)}
required
@ -209,7 +215,7 @@ export default function ProjectGraylog() {
</select>
<select
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={developerAgentId}
onChange={(event) => setDeveloperAgentId(event.target.value)}
required
@ -225,13 +231,13 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-4">
<input
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={streamId}
onChange={(event) => setStreamId(event.target.value)}
placeholder="stream_id (optionnel)"
/>
<input
className="rounded border border-gray-300 px-3 py-2 text-sm md:col-span-3"
className={`${inputClass} md:col-span-3`}
value={queryFilter}
onChange={(event) => setQueryFilter(event.target.value)}
placeholder="Filtre query Graylog"
@ -241,21 +247,21 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-3">
<input
type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={pollingIntervalMinutes}
onChange={(event) => setPollingIntervalMinutes(Number(event.target.value))}
min={1}
/>
<input
type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={lookbackMinutes}
onChange={(event) => setLookbackMinutes(Number(event.target.value))}
min={1}
/>
<input
type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
value={scoreThreshold}
onChange={(event) => setScoreThreshold(Number(event.target.value))}
min={1}
@ -267,28 +273,28 @@ export default function ProjectGraylog() {
<button
type="submit"
disabled={saving}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{saving ? "Sauvegarde..." : "Sauvegarder"}
</button>
<button
type="button"
onClick={() => void handleTestConnection()}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300"
className={buttonClass({ variant: "secondary" })}
>
Tester la connexion
</button>
<button
type="button"
onClick={() => void handleManualPoll()}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black"
className={buttonClass({ variant: "neutralDark" })}
>
Poll manuel
</button>
</div>
</form>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3>
{loading ? (
@ -311,7 +317,7 @@ export default function ProjectGraylog() {
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3>
{detections.length === 0 ? (

View file

@ -12,6 +12,16 @@ import {
} from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { Agent, LiveMessage, LiveSession } from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
pillClass,
} from "../ui/primitives";
interface LiveEventPayload {
project_id: string;
@ -303,34 +313,34 @@ export default function ProjectLiveAgent() {
}
return (
<div className="p-8 space-y-6">
<div className={pageStackClass}>
<div>
{projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
<Link to={`/projects/${projectId}`} className={backLinkClass}>
Back
</Link>
)}
<h2 className="text-xl font-bold">Live agent</h2>
<h2 className={pageTitleClass}>Live agent</h2>
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
{!moduleEnabled && (
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700">
<div className={noticeClass("warning", true)}>
Le module est désactivé. La lecture reste possible, mais la création de session et l'envoi de message sont bloqués.
</div>
)}
<form onSubmit={handleCreateSession} className="rounded-lg border border-gray-200 bg-white p-4">
<form onSubmit={handleCreateSession} className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
value={selectedAgentId}
onChange={(e) => setSelectedAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
>
{usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
@ -343,12 +353,12 @@ export default function ProjectLiveAgent() {
value={sessionTitle}
onChange={(e) => setSessionTitle(e.target.value)}
placeholder="Titre de session (optionnel)"
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
/>
<button
type="submit"
disabled={creatingSession || !selectedAgentId || !moduleEnabled}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{creatingSession ? "Création..." : "Créer la session"}
</button>
@ -356,7 +366,7 @@ export default function ProjectLiveAgent() {
</form>
<div className="grid gap-4 md:grid-cols-[260px,1fr]">
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className={`${cardContentClass} p-3`}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Sessions
</div>
@ -371,11 +381,7 @@ export default function ProjectLiveAgent() {
type="button"
key={session.id}
onClick={() => void handleSessionChange(session.id)}
className={`w-full rounded px-3 py-2 text-left text-sm ${
selectedSessionId === session.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
className={`${pillClass(selectedSessionId === session.id)} w-full text-left`}
>
<div className="truncate">{session.title}</div>
<div className="mt-1 text-[11px] opacity-70">
@ -426,7 +432,7 @@ export default function ProjectLiveAgent() {
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className={cardContentClass}>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-800">Discussion</div>
@ -505,7 +511,7 @@ export default function ProjectLiveAgent() {
: "Ton message..."
}
disabled={!selectedSessionId || sending || selectedSession?.status === "archived"}
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm"
className={`flex-1 ${inputClass}`}
/>
<button
type="submit"
@ -516,7 +522,7 @@ export default function ProjectLiveAgent() {
!moduleEnabled ||
selectedSession?.status === "archived"
}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black disabled:opacity-50"
className={buttonClass({ variant: "neutralDark" })}
>
{sending ? "Envoi..." : "Envoyer"}
</button>

View file

@ -3,6 +3,14 @@ import { Link, useParams } from "react-router-dom";
import { listProjectModules, setProjectModuleEnabled } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { ProjectModule } from "../../lib/types";
import {
buttonClass,
cardContentClass,
noticeClass,
pageClass,
pageTitleClass,
backLinkClass,
} from "../ui/primitives";
export default function ProjectModules() {
const { projectId } = useParams<{ projectId: string }>();
@ -42,18 +50,18 @@ export default function ProjectModules() {
}
return (
<div className="p-8">
<div className={pageClass}>
<div className="mb-6">
{projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
<Link to={`/projects/${projectId}`} className={backLinkClass}>
Back
</Link>
)}
<h2 className="text-xl font-bold">Modules du projet</h2>
<h2 className={pageTitleClass}>Modules du projet</h2>
</div>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
<div className={`mb-4 ${noticeClass("error", true)}`}>
{error}
</div>
)}
@ -62,7 +70,7 @@ export default function ProjectModules() {
{modules.map((mod) => (
<div
key={mod.id}
className="rounded-lg border border-gray-200 bg-white p-4"
className={cardContentClass}
>
<div className="flex items-start justify-between gap-4">
<div>
@ -72,7 +80,7 @@ export default function ProjectModules() {
{projectId && mod.module_key === "graylog_polling_auto_resolve" && (
<Link
to={`/projects/${projectId}/graylog`}
className="mt-2 inline-flex rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
className={`mt-2 ${buttonClass({ variant: "secondary", size: "xs" })}`}
>
Configurer
</Link>

View file

@ -11,6 +11,16 @@ import {
} from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { Agent, AgentTask } from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
textAreaClass,
} from "../ui/primitives";
interface TaskEventPayload {
project_id: string;
@ -152,34 +162,34 @@ export default function ProjectTasks() {
}
return (
<div className="space-y-6 p-8">
<div className={pageStackClass}>
<div>
{projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline">
<Link to={`/projects/${projectId}`} className={backLinkClass}>
Back
</Link>
)}
<h2 className="text-xl font-bold">Tâches agent</h2>
<h2 className={pageTitleClass}>Tâches agent</h2>
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
{!moduleEnabled && (
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700">
<div className={noticeClass("warning", true)}>
Le module est désactivé. Les tâches existantes restent visibles, mais la création et la relance sont bloquées.
</div>
)}
<form onSubmit={handleCreateTask} className="rounded-lg border border-gray-200 bg-white p-4">
<form onSubmit={handleCreateTask} className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Créer une tâche</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
>
{usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
@ -193,13 +203,13 @@ export default function ProjectTasks() {
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
className="rounded border border-gray-300 px-3 py-2 text-sm"
className={inputClass}
/>
<button
type="submit"
disabled={creating || !agentId || !title.trim() || !moduleEnabled}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{creating ? "Création..." : "Créer"}
</button>
@ -210,13 +220,13 @@ export default function ProjectTasks() {
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="Description détaillée"
className="mt-3 w-full rounded border border-gray-300 px-3 py-2 text-sm"
className={`mt-3 ${textAreaClass}`}
/>
</form>
<div className="space-y-3">
{tasks.map((task) => (
<div key={task.id} className="rounded-lg border border-gray-200 bg-white p-4">
<div key={task.id} className={cardContentClass}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{task.title}</div>
@ -234,13 +244,13 @@ export default function ProjectTasks() {
)}
{task.result && (
<div className="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900 whitespace-pre-wrap">
<div className={`mt-3 whitespace-pre-wrap ${noticeClass("success")}`}>
{task.result}
</div>
)}
{task.error && (
<div className="mt-3 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 whitespace-pre-wrap">
<div className={`mt-3 whitespace-pre-wrap ${noticeClass("error")}`}>
{task.error}
</div>
)}
@ -251,7 +261,7 @@ export default function ProjectTasks() {
type="button"
onClick={() => void handleRetry(task.id)}
disabled={workingTaskId === task.id || !moduleEnabled}
className="rounded bg-gray-900 px-3 py-1 text-xs text-white hover:bg-black disabled:opacity-50"
className={buttonClass({ variant: "neutralDark", size: "xs" })}
>
Relancer
</button>
@ -262,7 +272,7 @@ export default function ProjectTasks() {
type="button"
onClick={() => void handleCancel(task.id)}
disabled={workingTaskId === task.id}
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
>
Annuler
</button>

View file

@ -10,6 +10,14 @@ import {
import { getErrorMessage } from "../../lib/errors";
import type { Project, TuleapCredentialsSafe } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
import {
buttonClass,
cardClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
function normalizeScope(value: string): string | null {
return value === "" ? null : value;
@ -160,17 +168,17 @@ export default function SettingsPage() {
return (
<div className="max-w-lg mx-auto p-8">
<h2 className="text-xl font-bold mb-6">Settings</h2>
<h2 className={`${pageTitleClass} mb-6`}>Settings</h2>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className={`${cardClass} p-6`}>
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Scope</label>
<label className={labelClass}>Scope</label>
<select
value={selectedProjectId ?? ""}
onChange={(e) => setSelectedProjectId(normalizeScope(e.target.value))}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
<option value="">Global fallback</option>
{projects.map((project) => (
@ -191,54 +199,54 @@ export default function SettingsPage() {
) : null}
{usingGlobalFallback && (
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
<div className={`mb-4 ${noticeClass("warning")}`}>
Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé.
</div>
)}
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tuleap URL</label>
<label className={labelClass}>Tuleap URL</label>
<input
type="url"
value={tuleapUrl}
onChange={(e) => setTuleapUrl(e.target.value)}
required
placeholder="https://tuleap.example.com"
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<label className={labelClass}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<label className={labelClass}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
{success && (
<div className="text-green-700 text-sm bg-green-50 border border-green-200 rounded p-2">
<div className={noticeClass("success", true)}>
{success}
</div>
)}
@ -247,7 +255,7 @@ export default function SettingsPage() {
<button
type="submit"
disabled={saving || loadingScope}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{saving ? "Saving..." : "Save"}
</button>
@ -255,7 +263,7 @@ export default function SettingsPage() {
type="button"
onClick={handleTest}
disabled={testing || !existing || loadingScope}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
className={buttonClass({ variant: "secondary" })}
>
{testing ? "Testing..." : "Test connection"}
</button>
@ -264,7 +272,7 @@ export default function SettingsPage() {
type="button"
onClick={() => setIsDeleteModalOpen(true)}
disabled={deleting}
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50"
className={buttonClass({ variant: "danger" })}
>
{deleting ? "Deleting..." : "Delete"}
</button>

View file

@ -14,25 +14,15 @@ import {
import { getErrorMessage } from "../../lib/errors";
import type { ProcessedTicket, Worktree } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Analyzing":
return "bg-blue-100 text-blue-700";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
import TicketStatusBadge from "../ui/TicketStatusBadge";
import {
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageClass,
pageTitleClass,
} from "../ui/primitives";
function DiffViewer({ diff }: { diff: string }) {
if (!diff) {
@ -228,22 +218,19 @@ export default function TicketDetail() {
];
return (
<div className="p-8">
<div className={pageClass}>
<div className="mb-6 flex items-center justify-between">
<div>
<button onClick={() => navigate(-1)} className="mb-1 text-sm text-blue-600 hover:underline">
<button
onClick={() => navigate(-1)}
className={buttonClass({ variant: "ghost", size: "xs" })}
>
Back
</button>
<h2 className="flex items-center gap-3 text-xl font-bold">
<h2 className={`flex items-center gap-3 ${pageTitleClass}`}>
<span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span>
{ticket.artifact_title}
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
<TicketStatusBadge status={ticket.status} />
</h2>
</div>
<div className="flex gap-2">
@ -251,7 +238,7 @@ export default function TicketDetail() {
<button
onClick={handleRetry}
disabled={loading}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary", size: "sm" })}
>
Retry
</button>
@ -262,7 +249,7 @@ export default function TicketDetail() {
<button
onClick={handleCancel}
disabled={loading}
className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 disabled:opacity-50"
className={buttonClass({ variant: "dangerSoft", size: "sm" })}
>
Cancel
</button>
@ -271,7 +258,7 @@ export default function TicketDetail() {
</div>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
<div className={`mb-4 ${noticeClass("error")}`}>{error}</div>
)}
<div className="mb-6 flex gap-1 border-b border-gray-200">
@ -295,7 +282,7 @@ export default function TicketDetail() {
{tab === "info" && (
<div className="space-y-4">
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
<div className={`space-y-3 ${cardContentClass}`}>
<div>
<span className="text-sm text-gray-500">Status:</span>
<span className="ml-2 text-sm">{ticket.status}</span>
@ -340,7 +327,7 @@ export default function TicketDetail() {
</div>
{worktree && worktree.status === "Active" && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-gray-500">Target branch</p>
@ -350,7 +337,7 @@ export default function TicketDetail() {
onClick={() =>
setBranchInputMode((mode) => (mode === "select" ? "manual" : "select"))
}
className="text-xs text-blue-600 hover:underline"
className={buttonClass({ variant: "ghost", size: "xs" })}
>
{branchInputMode === "select"
? "Use manual input"
@ -364,7 +351,7 @@ export default function TicketDetail() {
<select
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
className={`flex-1 bg-white ${inputClass}`}
>
{availableBranches.map((branch) => (
<option key={branch} value={branch}>
@ -378,13 +365,13 @@ export default function TicketDetail() {
placeholder="Target branch (e.g. feature/login)"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
className={`flex-1 ${inputClass}`}
/>
)}
<button
onClick={handleApplyFix}
disabled={loading || branchesLoading || !targetBranch.trim()}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
className={buttonClass({ variant: "success", size: "sm" })}
>
Apply fix
</button>
@ -406,7 +393,7 @@ export default function TicketDetail() {
<button
onClick={() => setIsDeleteWorktreeModalOpen(true)}
disabled={loading}
className="text-sm text-red-600 hover:underline"
className={buttonClass({ variant: "dangerGhost", size: "xs" })}
>
Delete worktree
</button>
@ -414,7 +401,7 @@ export default function TicketDetail() {
)}
{worktree && worktree.status === "Merged" && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
<div className={noticeClass("info")}>
Fix applied to branch: {worktree.merged_into}
</div>
)}

View file

@ -2,25 +2,13 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getProject, listProcessedTickets } from "../../lib/api";
import type { ProcessedTicket, Project } from "../../lib/types";
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Analyzing":
return "bg-blue-100 text-blue-700";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
import TicketStatusBadge from "../ui/TicketStatusBadge";
import {
cardContentClass,
pageClass,
pageTitleClass,
pillClass,
} from "../ui/primitives";
export default function TicketList() {
const { projectId } = useParams();
@ -41,13 +29,13 @@ export default function TicketList() {
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
return (
<div className="p-8">
<div className={pageClass}>
<div className="mb-6 flex items-center justify-between">
<div>
<Link to={`/projects/${projectId}`} className="text-sm text-blue-600 hover:underline">
{project?.name}
</Link>
<h2 className="text-xl font-bold">Processed Tickets</h2>
<h2 className={pageTitleClass}>Processed Tickets</h2>
</div>
</div>
@ -56,11 +44,7 @@ export default function TicketList() {
<button
key={s}
onClick={() => setFilter(s)}
className={`rounded px-3 py-1 text-sm ${
filter === s
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
className={pillClass(filter === s)}
>
{s === "all" ? "All" : s}
{s !== "all" && (
@ -80,7 +64,7 @@ export default function TicketList() {
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className="block rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
className={`block transition-colors hover:border-blue-300 ${cardContentClass}`}
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
@ -100,13 +84,7 @@ export default function TicketList() {
)}
</div>
</div>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
<TicketStatusBadge status={ticket.status} className="shrink-0" />
</div>
</Link>
))}

View file

@ -1,4 +1,5 @@
import type { FilterGroup, Filter, TrackerField } from "../../lib/types";
import { buttonClass, cardContentClass, inputClass } from "../ui/primitives";
const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const;
@ -60,13 +61,13 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<div className="flex-1 border-t border-gray-200" />
</div>
)}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className={cardContentClass}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">Group {gi + 1}</span>
<button
type="button"
onClick={() => removeGroup(gi)}
className="text-xs text-red-500 hover:text-red-700"
className={buttonClass({ variant: "dangerGhost", size: "xs" })}
>
Remove group
</button>
@ -96,7 +97,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
value: [],
})
}
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1"
className={`flex-1 ${inputClass}`}
>
<option value="">Select field...</option>
{availableFields.map((f) => (
@ -112,7 +113,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
onChange={(e) =>
updateCondition(gi, ci, { ...cond, operator: e.target.value })
}
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
@ -125,7 +126,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button
type="button"
onClick={() => removeCondition(gi, ci)}
className="text-xs text-red-400 hover:text-red-600 whitespace-nowrap"
className={`${buttonClass({ variant: "dangerGhost", size: "xs" })} whitespace-nowrap`}
>
Remove
</button>
@ -162,7 +163,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button
type="button"
onClick={() => addCondition(gi)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
className={`${buttonClass({ variant: "ghost", size: "xs" })} mt-3`}
>
+ Add OR condition
</button>
@ -173,7 +174,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button
type="button"
onClick={addGroup}
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
className={`${buttonClass({ variant: "secondary", fullWidth: true })} border-2 border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600`}
>
+ Add filter group (AND)
</button>

View file

@ -11,6 +11,14 @@ import {
import { getErrorMessage } from "../../lib/errors";
import type { FilterGroup, TrackerField, Agent } from "../../lib/types";
import FilterBuilder from "./FilterBuilder";
import {
buttonClass,
cardContentClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
export default function TrackerConfig() {
const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>();
@ -165,7 +173,7 @@ export default function TrackerConfig() {
return (
<div className="max-w-2xl mx-auto p-8">
<h2 className="text-xl font-bold mb-6">
<h2 className={`${pageTitleClass} mb-6`}>
{isEditing ? "Edit tracker" : "Add tracker"}
</h2>
@ -175,21 +183,21 @@ export default function TrackerConfig() {
)}
{analystAgents.length === 0 || developerAgents.length === 0 ? (
<div className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
<div className={noticeClass("warning")}>
You need at least one analyst agent and one developer agent before creating a tracker.
</div>
) : null}
{isEditing && trackerStatus === "invalid" && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className={noticeClass("error")}>
This tracker is invalid. Select valid agents and save to reactivate it.
</div>
)}
{/* Basic fields */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className={`${cardContentClass} space-y-4`}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Tracker ID
</label>
<div className="flex gap-2">
@ -203,14 +211,14 @@ export default function TrackerConfig() {
}}
required
min={1}
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={`w-40 ${inputClass}`}
placeholder="e.g. 42"
/>
<button
type="button"
onClick={handleLoadFields}
disabled={!trackerId || fieldsLoading}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{fieldsLoading ? "Loading..." : "Load tracker fields"}
</button>
@ -218,7 +226,7 @@ export default function TrackerConfig() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Label
</label>
<input
@ -227,12 +235,12 @@ export default function TrackerConfig() {
onChange={(e) => setTrackerLabel(e.target.value)}
required
placeholder="e.g. Bugs"
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Polling interval (minutes)
</label>
<input
@ -241,7 +249,7 @@ export default function TrackerConfig() {
onChange={(e) => setPollingInterval(Number(e.target.value))}
required
min={1}
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={`w-40 ${inputClass}`}
/>
</div>
@ -270,17 +278,17 @@ export default function TrackerConfig() {
)}
{/* Agent config */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className={`${cardContentClass} space-y-4`}>
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Analyst agent
</label>
<select
value={analystAgentId}
onChange={(e) => setAnalystAgentId(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
<option value="">Select an analyst agent</option>
{analystAgents.map((agent) => (
@ -291,14 +299,14 @@ export default function TrackerConfig() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className={labelClass}>
Developer agent
</label>
<select
value={developerAgentId}
onChange={(e) => setDeveloperAgentId(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
>
<option value="">Select a developer agent</option>
{developerAgents.map((agent) => (
@ -311,7 +319,7 @@ export default function TrackerConfig() {
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
<div className={noticeClass("error", true)}>
{error}
</div>
)}
@ -320,14 +328,14 @@ export default function TrackerConfig() {
<button
type="submit"
disabled={loading || initializing}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary" })}
>
{loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"}
</button>
<button
type="button"
onClick={() => navigate(`/projects/${projectId}`)}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
className={buttonClass({ variant: "secondary" })}
>
Cancel
</button>

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
import type { WatchedTracker } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
import { buttonClass, cardContentClass } from "../ui/primitives";
interface Props {
trackers: WatchedTracker[];
@ -68,7 +69,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
{trackers.map((tracker) => (
<div
key={tracker.id}
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
className={`flex items-center justify-between gap-4 ${cardContentClass}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@ -98,13 +99,13 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
type="button"
onClick={() => handlePollNow(tracker)}
disabled={pollingIds.includes(tracker.id) || tracker.status !== "valid"}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:opacity-50"
className={buttonClass({ variant: "primary", size: "xs" })}
>
{pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
</button>
<Link
to={`/projects/${projectId}/trackers/${tracker.id}/edit`}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
className={buttonClass({ variant: "secondary", size: "xs" })}
>
Edit
</Link>
@ -112,14 +113,14 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
type="button"
onClick={() => handleToggleEnabled(tracker)}
disabled={tracker.status !== "valid"}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
className={buttonClass({ variant: "secondary", size: "xs" })}
>
{tracker.enabled ? "Pause" : "Resume"}
</button>
<button
type="button"
onClick={() => setTrackerToRemove(tracker)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200"
className={buttonClass({ variant: "dangerSoft", size: "xs" })}
>
Remove
</button>
@ -129,7 +130,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<Link
to={`/projects/${projectId}/trackers/new`}
className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
className={buttonClass({ variant: "primary" })}
>
Add tracker
</Link>

View file

@ -1,4 +1,5 @@
import { useEffect } from "react";
import { buttonClass, cardClass } from "./primitives";
interface ConfirmModalProps {
isOpen: boolean;
@ -45,7 +46,7 @@ export default function ConfirmModal({
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl"
className={`w-full max-w-md p-5 shadow-xl ${cardClass}`}
onClick={(event) => event.stopPropagation()}
>
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
@ -55,7 +56,7 @@ export default function ConfirmModal({
<button
type="button"
onClick={onCancel}
className="rounded bg-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-300"
className={buttonClass({ variant: "secondary", size: "sm" })}
>
{cancelLabel}
</button>
@ -63,7 +64,7 @@ export default function ConfirmModal({
type="button"
onClick={onConfirm}
disabled={confirmDisabled}
className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
className={buttonClass({ variant: "danger", size: "sm" })}
>
{confirmLabel}
</button>

View file

@ -0,0 +1,33 @@
import { cx } from "./primitives";
const statusClasses: Record<string, string> = {
Pending: "bg-yellow-100 text-yellow-700",
Analyzing: "bg-blue-100 text-blue-700",
Developing: "bg-purple-100 text-purple-700",
Done: "bg-green-100 text-green-700",
Error: "bg-red-100 text-red-700",
Cancelled: "bg-gray-100 text-gray-500",
};
export function ticketStatusClass(status: string): string {
return statusClasses[status] ?? "bg-gray-100 text-gray-700";
}
interface TicketStatusBadgeProps {
status: string;
className?: string;
}
export default function TicketStatusBadge({ status, className }: TicketStatusBadgeProps) {
return (
<span
className={cx(
"rounded-full px-2 py-0.5 text-xs font-medium",
ticketStatusClass(status),
className
)}
>
{status}
</span>
);
}

View file

@ -0,0 +1,89 @@
export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
export type ButtonVariant =
| "primary"
| "secondary"
| "danger"
| "dangerSoft"
| "dangerGhost"
| "neutralDark"
| "success"
| "ghost";
export type ButtonSize = "xs" | "sm" | "md" | "icon";
interface ButtonClassOptions {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
}
const buttonVariantClasses: Record<ButtonVariant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-700 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
dangerSoft: "bg-red-100 text-red-700 hover:bg-red-200",
dangerGhost: "bg-transparent text-red-600 hover:underline",
neutralDark: "bg-gray-900 text-white hover:bg-black",
success: "bg-green-600 text-white hover:bg-green-700",
ghost: "bg-transparent text-blue-600 hover:underline",
};
const buttonSizeClasses: Record<ButtonSize, string> = {
xs: "px-3 py-1 text-xs",
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
icon: "h-7 w-7 px-0 text-xs",
};
export function buttonClass({
variant = "secondary",
size = "md",
fullWidth = false,
}: ButtonClassOptions = {}): string {
return cx(
"inline-flex items-center justify-center rounded font-medium transition-colors disabled:opacity-50",
buttonVariantClasses[variant],
buttonSizeClasses[size],
fullWidth && "w-full"
);
}
export const pageClass = "p-8";
export const pageStackClass = "space-y-6 p-8";
export const pageTitleClass = "text-xl font-bold";
export const backLinkClass = "mb-1 inline-flex text-sm text-blue-600 hover:underline";
export const cardClass = "rounded-lg border border-gray-200 bg-white";
export const cardContentClass = `${cardClass} p-4`;
export const labelClass = "mb-1 block text-sm font-medium text-gray-700";
export const inputClass =
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
export const textAreaClass =
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
export type NoticeTone = "error" | "success" | "warning" | "info";
const noticeToneClasses: Record<NoticeTone, string> = {
error: "border-red-200 bg-red-50 text-red-700",
success: "border-green-200 bg-green-50 text-green-700",
warning: "border-amber-200 bg-amber-50 text-amber-700",
info: "border-blue-200 bg-blue-50 text-blue-700",
};
export function noticeClass(tone: NoticeTone, compact = false): string {
return cx(
"rounded border text-sm",
compact ? "p-2" : "p-3",
noticeToneClasses[tone]
);
}
export function pillClass(active: boolean): string {
return active
? "rounded px-3 py-1 text-sm bg-gray-900 text-white"
: "rounded px-3 py-1 text-sm bg-gray-100 text-gray-700 hover:bg-gray-200";
}