mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(panel): responsive two-column layout for the Conventions editor
The per-project Conventions tab was one long single column in a narrow modal, wasting all the horizontal space. Lay the sections out in a responsive grid — Module boundaries | Rules, then Waivers | Custom rules — with Recent violations full-width on its own row, and widen the modal on large viewports (only on the Conventions tab; Settings stays compact). Collapses to a single column on mobile and is capped at xl so it stays sane up to a 27" display.
This commit is contained in:
@@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
- **`ollama-init` is best-effort and gates startup on the models being present**, so a slow or unreachable model registry can no longer down a fully-cached deployment.
|
- **`ollama-init` is best-effort and gates startup on the models being present**, so a slow or unreachable model registry can no longer down a fully-cached deployment.
|
||||||
- **A PM can recover its own coordination task from `needs_revision`**, and lifecycle-transition notes are kept off the human-facing `quick_context` / `dev_notes` columns.
|
- **A PM can recover its own coordination task from `needs_revision`**, and lifecycle-transition notes are kept off the human-facing `quick_context` / `dev_notes` columns.
|
||||||
- **Panel:** a copyable task-id chip with a stable, non-shifting task header, clickable Branch / PR links with a branch-copy button, and clearer agent status badges.
|
- **Panel:** a copyable task-id chip with a stable, non-shifting task header, clickable Branch / PR links with a branch-copy button, and clearer agent status badges.
|
||||||
|
- **Panel:** the per-project Conventions editor lays out in a responsive two-column grid (Module boundaries | Rules, then Waivers | Custom rules) with Recent violations full-width, inside a wider modal on large viewports — instead of one long single column. It collapses to a single column on mobile and is capped so it stays sane up to a 27" display.
|
||||||
|
|
||||||
## [0.8.0] - 2026-06-20
|
## [0.8.0] - 2026-06-20
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ const FORBIDDABLE_KINDS: DefinitionKind[] = [
|
|||||||
|
|
||||||
function actionToast(verb: string, result: ConventionsActionResult): void {
|
function actionToast(verb: string, result: ConventionsActionResult): void {
|
||||||
if (result.created && result.pr_number != null) {
|
if (result.created && result.pr_number != null) {
|
||||||
toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`);
|
toast.success(
|
||||||
|
`${verb}: opened PR #${result.pr_number} on ${result.branch}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.success(
|
toast.success(
|
||||||
`${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`,
|
`${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`,
|
||||||
@@ -108,10 +110,14 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const updateModule = (index: number, next: Partial<ConventionsModule>) =>
|
const updateModule = (index: number, next: Partial<ConventionsModule>) =>
|
||||||
edit({
|
edit({
|
||||||
modules: standard.modules.map((m, i) => (i === index ? { ...m, ...next } : m)),
|
modules: standard.modules.map((m, i) =>
|
||||||
|
i === index ? { ...m, ...next } : m,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const addModule = () =>
|
const addModule = () =>
|
||||||
edit({ modules: [...standard.modules, { path: "", purpose: "", forbidden: [] }] });
|
edit({
|
||||||
|
modules: [...standard.modules, { path: "", purpose: "", forbidden: [] }],
|
||||||
|
});
|
||||||
const removeModule = (index: number) =>
|
const removeModule = (index: number) =>
|
||||||
edit({ modules: standard.modules.filter((_, i) => i !== index) });
|
edit({ modules: standard.modules.filter((_, i) => i !== index) });
|
||||||
const toggleForbidden = (index: number, kind: DefinitionKind) => {
|
const toggleForbidden = (index: number, kind: DefinitionKind) => {
|
||||||
@@ -124,7 +130,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const updateCustom = (index: number, next: Partial<ConventionsCustomRule>) =>
|
const updateCustom = (index: number, next: Partial<ConventionsCustomRule>) =>
|
||||||
edit({
|
edit({
|
||||||
custom: standard.custom.map((c, i) => (i === index ? { ...c, ...next } : c)),
|
custom: standard.custom.map((c, i) =>
|
||||||
|
i === index ? { ...c, ...next } : c,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const addCustom = () =>
|
const addCustom = () =>
|
||||||
edit({
|
edit({
|
||||||
@@ -138,10 +146,14 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const updateWaiver = (index: number, next: Partial<ConventionsWaiver>) =>
|
const updateWaiver = (index: number, next: Partial<ConventionsWaiver>) =>
|
||||||
edit({
|
edit({
|
||||||
waivers: standard.waivers.map((w, i) => (i === index ? { ...w, ...next } : w)),
|
waivers: standard.waivers.map((w, i) =>
|
||||||
|
i === index ? { ...w, ...next } : w,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const addWaiver = () =>
|
const addWaiver = () =>
|
||||||
edit({ waivers: [...standard.waivers, { path: "", rule: "", reason: "" }] });
|
edit({
|
||||||
|
waivers: [...standard.waivers, { path: "", rule: "", reason: "" }],
|
||||||
|
});
|
||||||
const removeWaiver = (index: number) =>
|
const removeWaiver = (index: number) =>
|
||||||
edit({ waivers: standard.waivers.filter((_, i) => i !== index) });
|
edit({ waivers: standard.waivers.filter((_, i) => i !== index) });
|
||||||
|
|
||||||
@@ -152,6 +164,240 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
const degraded = status === "degraded";
|
const degraded = status === "degraded";
|
||||||
const usingDefaults = status === "missing" || status === "unknown";
|
const usingDefaults = status === "missing" || status === "unknown";
|
||||||
|
|
||||||
|
const moduleBoundaries = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Module boundaries</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Which definition kinds are forbidden in each module. Click a kind to
|
||||||
|
toggle it.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{standard.modules.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No modules mapped yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{standard.modules.map((module, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 rounded-md border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={module.path}
|
||||||
|
placeholder="path/to/module"
|
||||||
|
onChange={(e) => updateModule(index, { path: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeModule(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={module.purpose}
|
||||||
|
placeholder="what this module is for"
|
||||||
|
onChange={(e) => updateModule(index, { purpose: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{FORBIDDABLE_KINDS.map((kind) => (
|
||||||
|
<Badge
|
||||||
|
key={kind}
|
||||||
|
variant={
|
||||||
|
module.forbidden.includes(kind) ? "destructive" : "outline"
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleForbidden(index, kind)}
|
||||||
|
>
|
||||||
|
no {kind}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addModule}>
|
||||||
|
Add module
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rules = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Rules</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Toggle a rule between warn (advisory) and block (refuses the gate).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.values(standard.rules).map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.name}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{rule.name.replace(/_/g, " ")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-10 text-right text-xs text-muted-foreground">
|
||||||
|
{rule.level}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={rule.level === "block"}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRuleLevel(rule.name, checked ? "block" : "warn")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const waivers = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Waivers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Accountable escapes — exempt a file from a rule with a reason
|
||||||
|
(reviewed in the PR, never a silent in-code suppression).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{standard.waivers.map((waiver, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 rounded-md border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={waiver.path}
|
||||||
|
placeholder="path/to/file.py"
|
||||||
|
onChange={(e) => updateWaiver(index, { path: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={waiver.rule}
|
||||||
|
placeholder="rule name"
|
||||||
|
onChange={(e) => updateWaiver(index, { rule: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeWaiver(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={waiver.reason}
|
||||||
|
placeholder="why this is waived"
|
||||||
|
onChange={(e) => updateWaiver(index, { reason: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addWaiver}>
|
||||||
|
Add waiver
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const customRules = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Custom rules</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Project-specific regex rules — a pattern, a message, and a level.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{standard.custom.map((rule, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 rounded-md border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={rule.id}
|
||||||
|
placeholder="rule-id"
|
||||||
|
onChange={(e) => updateCustom(index, { id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-right text-xs text-muted-foreground">
|
||||||
|
{rule.level}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={rule.level === "block"}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateCustom(index, { level: checked ? "block" : "warn" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCustom(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={rule.pattern}
|
||||||
|
placeholder="regex pattern"
|
||||||
|
onChange={(e) => updateCustom(index, { pattern: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={rule.message}
|
||||||
|
placeholder="message shown when it matches"
|
||||||
|
onChange={(e) => updateCustom(index, { message: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addCustom}>
|
||||||
|
Add custom rule
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentViolations = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Recent violations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The latest findings recorded across this project's tasks.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{(!findings || findings.length === 0) && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No violations recorded yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(findings ?? []).map((finding, index) => (
|
||||||
|
<div
|
||||||
|
key={`${finding.file}:${finding.line}:${finding.rule}:${index}`}
|
||||||
|
className="flex items-start justify-between gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<code>
|
||||||
|
{finding.file}:{finding.line}
|
||||||
|
</code>{" "}
|
||||||
|
<span className="text-muted-foreground">{finding.message}</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={finding.level === "block" ? "destructive" : "secondary"}
|
||||||
|
>
|
||||||
|
{finding.rule}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
{degraded && (
|
{degraded && (
|
||||||
@@ -171,7 +417,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
{usingDefaults && (
|
{usingDefaults && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm">Using auto-derived defaults</CardTitle>
|
<CardTitle className="text-sm">
|
||||||
|
Using auto-derived defaults
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
No <code>.roboco/conventions.yml</code> is committed yet. These
|
No <code>.roboco/conventions.yml</code> is committed yet. These
|
||||||
rules are auto-derived from the repository and are already
|
rules are auto-derived from the repository and are already
|
||||||
@@ -181,227 +429,18 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
{/* Two columns on wide viewports so the modal isn't a long single column;
|
||||||
<CardHeader>
|
Module boundaries | Rules, then Waivers | Custom rules. Each cell keeps
|
||||||
<CardTitle className="text-sm">Module boundaries</CardTitle>
|
its natural height (items-start) and stacks to one column on mobile. */}
|
||||||
<CardDescription>
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
Which definition kinds are forbidden in each module. Click a kind to
|
{moduleBoundaries}
|
||||||
toggle it.
|
{rules}
|
||||||
</CardDescription>
|
{waivers}
|
||||||
</CardHeader>
|
{customRules}
|
||||||
<CardContent className="space-y-3">
|
</div>
|
||||||
{standard.modules.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">No modules mapped yet.</p>
|
|
||||||
)}
|
|
||||||
{standard.modules.map((module, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="space-y-2 rounded-md border border-border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={module.path}
|
|
||||||
placeholder="path/to/module"
|
|
||||||
onChange={(e) => updateModule(index, { path: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeModule(index)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={module.purpose}
|
|
||||||
placeholder="what this module is for"
|
|
||||||
onChange={(e) => updateModule(index, { purpose: e.target.value })}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FORBIDDABLE_KINDS.map((kind) => (
|
|
||||||
<Badge
|
|
||||||
key={kind}
|
|
||||||
variant={
|
|
||||||
module.forbidden.includes(kind) ? "destructive" : "outline"
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => toggleForbidden(index, kind)}
|
|
||||||
>
|
|
||||||
no {kind}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="outline" size="sm" onClick={addModule}>
|
|
||||||
Add module
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
{/* Recent violations spans the full width on its own row. */}
|
||||||
<CardHeader>
|
{recentViolations}
|
||||||
<CardTitle className="text-sm">Rules</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Toggle a rule between warn (advisory) and block (refuses the gate).
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.values(standard.rules).map((rule) => (
|
|
||||||
<div
|
|
||||||
key={rule.name}
|
|
||||||
className="flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{rule.name.replace(/_/g, " ")}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-10 text-right text-xs text-muted-foreground">
|
|
||||||
{rule.level}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={rule.level === "block"}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setRuleLevel(rule.name, checked ? "block" : "warn")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Custom rules</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Project-specific regex rules — a pattern, a message, and a level.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{standard.custom.map((rule, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="space-y-2 rounded-md border border-border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={rule.id}
|
|
||||||
placeholder="rule-id"
|
|
||||||
onChange={(e) => updateCustom(index, { id: e.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="w-10 text-right text-xs text-muted-foreground">
|
|
||||||
{rule.level}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={rule.level === "block"}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateCustom(index, { level: checked ? "block" : "warn" })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeCustom(index)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={rule.pattern}
|
|
||||||
placeholder="regex pattern"
|
|
||||||
onChange={(e) => updateCustom(index, { pattern: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={rule.message}
|
|
||||||
placeholder="message shown when it matches"
|
|
||||||
onChange={(e) => updateCustom(index, { message: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="outline" size="sm" onClick={addCustom}>
|
|
||||||
Add custom rule
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Waivers</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Accountable escapes — exempt a file from a rule with a reason
|
|
||||||
(reviewed in the PR, never a silent in-code suppression).
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{standard.waivers.map((waiver, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="space-y-2 rounded-md border border-border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={waiver.path}
|
|
||||||
placeholder="path/to/file.py"
|
|
||||||
onChange={(e) => updateWaiver(index, { path: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={waiver.rule}
|
|
||||||
placeholder="rule name"
|
|
||||||
onChange={(e) => updateWaiver(index, { rule: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeWaiver(index)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={waiver.reason}
|
|
||||||
placeholder="why this is waived"
|
|
||||||
onChange={(e) => updateWaiver(index, { reason: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="outline" size="sm" onClick={addWaiver}>
|
|
||||||
Add waiver
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Recent violations</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
The latest findings recorded across this project's tasks.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{(!findings || findings.length === 0) && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No violations recorded yet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{(findings ?? []).map((finding, index) => (
|
|
||||||
<div
|
|
||||||
key={`${finding.file}:${finding.line}:${finding.rule}:${index}`}
|
|
||||||
className="flex items-start justify-between gap-4 text-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<code>
|
|
||||||
{finding.file}:{finding.line}
|
|
||||||
</code>{" "}
|
|
||||||
<span className="text-muted-foreground">{finding.message}</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={finding.level === "block" ? "destructive" : "secondary"}
|
|
||||||
>
|
|
||||||
{finding.rule}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button
|
<Button
|
||||||
@@ -417,7 +456,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
|
|||||||
disabled={(draft == null && !usingDefaults) || save.isPending}
|
disabled={(draft == null && !usingDefaults) || save.isPending}
|
||||||
onClick={() => save.mutate(draft ?? standard)}
|
onClick={() => save.mutate(draft ?? standard)}
|
||||||
>
|
>
|
||||||
{usingDefaults && draft == null ? "Save defaults to repo" : "Save to repo"}
|
{usingDefaults && draft == null
|
||||||
|
? "Save defaults to repo"
|
||||||
|
: "Save to repo"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,12 +22,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@/components/ui/tabs";
|
|
||||||
import { ConventionsTab } from "@/components/conventions/conventions-tab";
|
import { ConventionsTab } from "@/components/conventions/conventions-tab";
|
||||||
import { Key, KeyRound } from "lucide-react";
|
import { Key, KeyRound } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -65,10 +60,16 @@ function EditProjectForm({
|
|||||||
const [isActive, setIsActive] = useState(project.is_active);
|
const [isActive, setIsActive] = useState(project.is_active);
|
||||||
const [testCommand, setTestCommand] = useState(project.test_command || "");
|
const [testCommand, setTestCommand] = useState(project.test_command || "");
|
||||||
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
|
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
|
||||||
const [formatCommand, setFormatCommand] = useState(project.format_command || "");
|
const [formatCommand, setFormatCommand] = useState(
|
||||||
const [typecheckCommand, setTypecheckCommand] = useState(project.typecheck_command || "");
|
project.format_command || "",
|
||||||
|
);
|
||||||
|
const [typecheckCommand, setTypecheckCommand] = useState(
|
||||||
|
project.typecheck_command || "",
|
||||||
|
);
|
||||||
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
|
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
|
||||||
const [qualityCommand, setQualityCommand] = useState(project.quality_command || "");
|
const [qualityCommand, setQualityCommand] = useState(
|
||||||
|
project.quality_command || "",
|
||||||
|
);
|
||||||
|
|
||||||
// Token handling
|
// Token handling
|
||||||
const [newToken, setNewToken] = useState("");
|
const [newToken, setNewToken] = useState("");
|
||||||
@@ -113,7 +114,7 @@ function EditProjectForm({
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Failed to update project: ${error instanceof Error ? error.message : "Unknown error"}`
|
`Failed to update project: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,7 +123,9 @@ function EditProjectForm({
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Project</DialogTitle>
|
<DialogTitle>Edit Project</DialogTitle>
|
||||||
<DialogDescription>Update project settings. Slug cannot be changed.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Update project settings. Slug cannot be changed.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{/* Slug (read-only) */}
|
{/* Slug (read-only) */}
|
||||||
@@ -165,18 +168,25 @@ function EditProjectForm({
|
|||||||
{project.has_git_token ? (
|
{project.has_git_token ? (
|
||||||
<>
|
<>
|
||||||
<Key className="h-4 w-4 text-green-500" />
|
<Key className="h-4 w-4 text-green-500" />
|
||||||
<span className="text-green-600 dark:text-green-400">Token is set</span>
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
Token is set
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||||
<span className="text-amber-600 dark:text-amber-400">No token configured</span>
|
<span className="text-amber-600 dark:text-amber-400">
|
||||||
|
No token configured
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
{project.has_git_token && (
|
{project.has_git_token && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="clear-token" className="text-xs text-muted-foreground">
|
<Label
|
||||||
|
htmlFor="clear-token"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
Clear token
|
Clear token
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -204,7 +214,8 @@ function EditProjectForm({
|
|||||||
placeholder="ghp_xxxxxxxxxxxx..."
|
placeholder="ghp_xxxxxxxxxxxx..."
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Personal access token with repo access for clone, push, and PR operations
|
Personal access token with repo access for clone, push, and PR
|
||||||
|
operations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +224,10 @@ function EditProjectForm({
|
|||||||
{/* Assigned Cell */}
|
{/* Assigned Cell */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="assigned_cell">Assigned Cell *</Label>
|
<Label htmlFor="assigned_cell">Assigned Cell *</Label>
|
||||||
<Select value={assignedCell} onValueChange={(value: Team) => setAssignedCell(value)}>
|
<Select
|
||||||
|
value={assignedCell}
|
||||||
|
onValueChange={(value: Team) => setAssignedCell(value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select cell" />
|
<SelectValue placeholder="Select cell" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -241,7 +255,11 @@ function EditProjectForm({
|
|||||||
{/* Active Status */}
|
{/* Active Status */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="is_active">Active</Label>
|
<Label htmlFor="is_active">Active</Label>
|
||||||
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
|
<Switch
|
||||||
|
id="is_active"
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={setIsActive}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Options Toggle */}
|
{/* Advanced Options Toggle */}
|
||||||
@@ -315,8 +333,8 @@ function EditProjectForm({
|
|||||||
placeholder="make gate"
|
placeholder="make gate"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Fast pre-submit gate (lint + types + complexity, no tests) run in
|
Fast pre-submit gate (lint + types + complexity, no tests) run
|
||||||
the dev's workspace at hand-off to QA.
|
in the dev's workspace at hand-off to QA.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -335,12 +353,25 @@ function EditProjectForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main dialog component - handles data fetching and dialog state
|
// Main dialog component - handles data fetching and dialog state
|
||||||
export function EditProjectDialog({ projectId, open, onOpenChange }: EditProjectDialogProps) {
|
export function EditProjectDialog({
|
||||||
|
projectId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: EditProjectDialogProps) {
|
||||||
const { data: project, isLoading } = useProject(projectId);
|
const { data: project, isLoading } = useProject(projectId);
|
||||||
|
const [tab, setTab] = useState("settings");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[525px] max-h-[90vh] overflow-y-auto">
|
{/* Settings is a compact form; Conventions uses a two-column grid, so it
|
||||||
|
gets a wider, responsive modal (capped so it stays sane on a 27"). */}
|
||||||
|
<DialogContent
|
||||||
|
className={`max-h-[90vh] overflow-y-auto ${
|
||||||
|
tab === "conventions"
|
||||||
|
? "sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl"
|
||||||
|
: "sm:max-w-[525px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
@@ -349,7 +380,7 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
|
|||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
) : project ? (
|
) : project ? (
|
||||||
<Tabs defaultValue="settings">
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
<TabsTrigger value="conventions">Conventions</TabsTrigger>
|
<TabsTrigger value="conventions">Conventions</TabsTrigger>
|
||||||
@@ -368,7 +399,9 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-muted-foreground">Project not found</div>
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
Project not found
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user