feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,406 @@
/**
* Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested
* `Split` / `Grid` containers down to `Leaf` cells, each hosting a
* {@link TerminalView} (L3). Provides the spreadsheet-style interactions:
*
* - **resize**: drag the separator between two split children → recomputes the
* two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize;
* - **split**: per-cell buttons split a leaf into rows/columns → Split;
* - **merge**: when a split has > 1 child, a cell can collapse its parent split
* onto itself (spreadsheet-like cell merge) → Merge.
* - **agent**: each leaf cell has a dropdown to pin an agent; persisted via
* `setCellAgent` in the layout (#3).
*
* Pure presentation: all behaviour comes from {@link useLayout}, which speaks to
* the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure
* {@link normalizeWeights} function, kept out of the render for testability.
*/
import { useEffect, useRef, useState } from "react";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import { TerminalView } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
interface LayoutGridProps {
/** Project whose layout to render. */
projectId: string;
/** Working directory new terminals open in (the project root). */
cwd: string;
/** Active layout id; when provided the grid loads/mutates this layout. */
layoutId?: string;
}
export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
const vm = useLayout(projectId, layoutId);
if (!vm.layout) {
return (
<div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
{vm.error ? (
<p role="alert" style={{ color: "crimson" }}>
{vm.error}
</p>
) : (
<p>Loading layout</p>
)}
</div>
);
}
return (
<div
data-testid="layout-grid"
style={{ width: "100%", height: "100%", position: "relative" }}
>
{vm.error && (
<p role="alert" style={{ color: "crimson", margin: 0 }}>
{vm.error}
</p>
)}
<NodeView
node={vm.layout.root}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
/>
</div>
);
}
interface NodeViewProps {
node: LayoutNode;
cwd: string;
vm: LayoutViewModel;
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
}
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
<LeafView
id={node.node.id}
session={node.node.session ?? null}
agent={node.node.agent ?? null}
cwd={cwd}
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
/>
);
case "split":
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
case "grid":
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
}
}
interface LeafViewProps {
id: string;
session: string | null;
agent: string | null;
cwd: string;
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
}
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
const { agent: agentGateway } = useGateways();
// Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
if (!agentGateway) return;
let cancelled = false;
agentGateway.listAgents(projectId).then((list) => {
if (!cancelled) setAgents(list);
}).catch(() => {/* ignore — dropdown stays empty */});
return () => { cancelled = true; };
}, [agentGateway, projectId]);
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
const terminalOpener = agentGateway && agentId
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
agentGateway.launchAgent(projectId, agentId, opts, onData)
: undefined;
return (
<div
data-testid="layout-leaf"
data-node-id={id}
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 2,
right: 2,
zIndex: 2,
display: "flex",
gap: 2,
alignItems: "center",
}}
>
{/* Agent selector */}
<select
aria-label={`agent selector ${id}`}
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
void vm.setCellAgent(id, val === "" ? null : val);
}}
style={{
fontSize: 11,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
padding: "1px 2px",
maxWidth: 100,
}}
>
<option value="">Plain</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<button
type="button"
title="Split into columns"
aria-label={`split ${id} columns`}
onClick={() => void vm.split(id, "row")}
>
</button>
<button
type="button"
title="Split into rows"
aria-label={`split ${id} rows`}
onClick={() => void vm.split(id, "column")}
>
</button>
{canMerge && (
<button
type="button"
title="Merge: keep this cell, drop its siblings"
aria-label={`merge ${id}`}
onClick={() =>
void vm.merge(parentSplit.container, parentSplit.index)
}
>
</button>
)}
</div>
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
</div>
);
}
interface SplitViewProps {
split: Extract<LayoutNode, { type: "split" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
}
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
// Live drag preview: while a separator is dragged we override the rendered
// sizes locally (so the split moves under the cursor) and only commit the new
// weights to the backend on release (avoids a mutate round-trip per mousemove).
const [dragWeights, setDragWeights] = useState<number[] | null>(null);
const sizes = normalizeWeights(dragWeights ?? baseWeights);
return (
<div
ref={containerRef}
data-testid="layout-split"
data-direction={split.direction}
style={{
display: "flex",
flexDirection: isRow ? "row" : "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{split.children.map((child, i) => (
<div key={keyOf(child.node, i)} style={{ display: "contents" }}>
<div
style={{
flexBasis: `${sizes[i]}%`,
flexGrow: 0,
flexShrink: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={child.node}
cwd={cwd}
vm={vm}
projectId={projectId}
parentSplit={{
container: split.id,
index: i,
siblings: split.children.length,
}}
/>
</div>
{i < split.children.length - 1 && (
<Separator
isRow={isRow}
container={containerRef}
onDragMove={(deltaFraction) =>
setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
}
onDragEnd={(deltaFraction) => {
const weights = resizeAdjacent(baseWeights, i, deltaFraction);
setDragWeights(null);
void vm.resize(split.id, weights);
}}
/>
)}
</div>
))}
</div>
);
}
interface SeparatorProps {
isRow: boolean;
container: React.RefObject<HTMLDivElement | null>;
/** Called continuously during the drag with the signed fraction of the container extent (live preview). */
onDragMove: (deltaFraction: number) => void;
/** Called on release with the final fraction (commit). */
onDragEnd: (deltaFraction: number) => void;
}
/**
* A draggable separator between two split children. It captures the pointer and
* reports the dragged distance (as a fraction of the container extent) **live**
* on every move so the split tracks the cursor, then once more on release so the
* parent can commit the new weights.
*/
function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) {
const startRef = useRef<number | null>(null);
function fraction(clientPos: number): number | null {
if (startRef.current === null) return null;
const rect = container.current?.getBoundingClientRect();
const extent = rect ? (isRow ? rect.width : rect.height) : 0;
if (extent <= 0) return null;
return (clientPos - startRef.current) / extent;
}
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
startRef.current = isRow ? e.clientX : e.clientY;
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (startRef.current === null) return;
const f = fraction(isRow ? e.clientX : e.clientY);
if (f !== null) onDragMove(f);
}
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
const f = fraction(isRow ? e.clientX : e.clientY);
startRef.current = null;
if (f !== null) onDragEnd(f);
}
return (
<div
role="separator"
aria-orientation={isRow ? "vertical" : "horizontal"}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
flex: "0 0 6px",
cursor: isRow ? "col-resize" : "row-resize",
background: "#3a3a3a",
touchAction: "none",
}}
/>
);
}
interface GridViewProps {
grid: Extract<LayoutNode, { type: "grid" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
}
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
const rows = normalizeWeights(grid.rowWeights)
.map((p) => `${p}fr`)
.join(" ");
return (
<div
data-testid="layout-grid-container"
style={{
display: "grid",
gridTemplateColumns: cols,
gridTemplateRows: rows,
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{grid.cells.map((cell, i) => (
<div
key={keyOf(cell.node, i)}
style={{
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
</div>
))}
</div>
);
}
/** A stable-ish React key for a node (its id when it has one). */
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}