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:
Renn F
2026-06-22 21:41:37 +02:00
parent f1b197def0
commit 717fb56486
3 changed files with 326 additions and 251 deletions
+1
View File
@@ -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,35 +164,7 @@ 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";
return ( const moduleBoundaries = (
<div className="space-y-4 py-2">
{degraded && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-sm">
Conventions degraded committed file unparseable
</CardTitle>
<CardDescription>
The committed <code>.roboco/conventions.yml</code> could not be
parsed; the effective map fell back to the last-good cache plus
auto-derived defaults. Restore re-commits the last-good file.
</CardDescription>
</CardHeader>
</Card>
)}
{usingDefaults && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Using auto-derived defaults</CardTitle>
<CardDescription>
No <code>.roboco/conventions.yml</code> is committed yet. These
rules are auto-derived from the repository and are already
enforced. Edit them below and Save to repo to make them canonical.
</CardDescription>
</CardHeader>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Module boundaries</CardTitle> <CardTitle className="text-sm">Module boundaries</CardTitle>
@@ -191,7 +175,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{standard.modules.length === 0 && ( {standard.modules.length === 0 && (
<p className="text-sm text-muted-foreground">No modules mapped yet.</p> <p className="text-sm text-muted-foreground">
No modules mapped yet.
</p>
)} )}
{standard.modules.map((module, index) => ( {standard.modules.map((module, index) => (
<div <div
@@ -238,7 +224,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
);
const rules = (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Rules</CardTitle> <CardTitle className="text-sm">Rules</CardTitle>
@@ -268,7 +256,57 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
))} ))}
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Custom rules</CardTitle> <CardTitle className="text-sm">Custom rules</CardTitle>
@@ -322,53 +360,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
);
<Card> const recentViolations = (
<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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Recent violations</CardTitle> <CardTitle className="text-sm">Recent violations</CardTitle>
@@ -402,6 +396,51 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
))} ))}
</CardContent> </CardContent>
</Card> </Card>
);
return (
<div className="space-y-4 py-2">
{degraded && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-sm">
Conventions degraded committed file unparseable
</CardTitle>
<CardDescription>
The committed <code>.roboco/conventions.yml</code> could not be
parsed; the effective map fell back to the last-good cache plus
auto-derived defaults. Restore re-commits the last-good file.
</CardDescription>
</CardHeader>
</Card>
)}
{usingDefaults && (
<Card>
<CardHeader>
<CardTitle className="text-sm">
Using auto-derived defaults
</CardTitle>
<CardDescription>
No <code>.roboco/conventions.yml</code> is committed yet. These
rules are auto-derived from the repository and are already
enforced. Edit them below and Save to repo to make them canonical.
</CardDescription>
</CardHeader>
</Card>
)}
{/* Two columns on wide viewports so the modal isn't a long single column;
Module boundaries | Rules, then Waivers | Custom rules. Each cell keeps
its natural height (items-start) and stacks to one column on mobile. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
{moduleBoundaries}
{rules}
{waivers}
{customRules}
</div>
{/* Recent violations spans the full width on its own row. */}
{recentViolations}
<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&apos;s workspace at hand-off to QA. in the dev&apos;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>