sidebar: click-to-rename org/company/team + strip synthetics from world viz
Two related pieces of the "kill My Workspace" cleanup, landed
together because they share the same file:
Backend
- Three tiny inline-rename endpoints:
PATCH /api/orgs/{id}/name
PATCH /api/companies/{id}/name
PATCH /api/teams/{id}/name
Each takes { name: string }, trims + rejects empty, returns 204.
Backed by rename_org / rename_company / rename_team in cm-db —
single-row UPDATEs scoped to the caller's workspace, NotFound if
the id isn't visible.
- Registered next to the existing PATCH /:id (topology) routes so
they don't collide.
Frontend
- StructureTree accepts an optional onRename and canRename.
TreeRow: click on the label text of a renamable node → the span
becomes an <input>, focus + select-all, save on Enter or blur,
cancel on Escape. The rest of the row (row chevron / row body)
still navigates + selects as before, so single-click behaviour
is preserved for everything except the name text itself.
react-hooks/set-state-in-effect avoided by resetting the draft
in the enterEdit() click handler instead of inside a useEffect.
- Dashboard passes canRename={item.level !== "claw" && !synthetic}
(claws don't have a rename endpoint yet; synthetic scaffolding
gets reified into real rows in the next commit — the wizard
auto-materialize + orphan-migration dialog).
onRename fires the corresponding PATCH and calls router.refresh()
so the label lands in every consumer of the tree.
- World viz seed: new stripSynthetics(roots) helper walks the tree
and lifts children of any synthetic container up to their
grandparent's level. worldCanvasRoots feeds through this before
narrowRoots(). Result: the Live viz no longer shows "My Workspace"
or "Teams" nodes — real agents orbit the world root directly
(which is what you were asking for). Sidebar tree still shows
them so orphaned agents remain visible until the migration lands.
This commit is contained in:
@@ -356,6 +356,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.patch(routes::teams::patch_team)
|
.patch(routes::teams::patch_team)
|
||||||
.delete(routes::teams::delete_team),
|
.delete(routes::teams::delete_team),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/teams/{id}/name",
|
||||||
|
axum::routing::patch(routes::teams::rename_team),
|
||||||
|
)
|
||||||
.route("/api/teams/{id}/run", post(routes::teams::run_team))
|
.route("/api/teams/{id}/run", post(routes::teams::run_team))
|
||||||
.route(
|
.route(
|
||||||
"/api/companies",
|
"/api/companies",
|
||||||
@@ -367,6 +371,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.patch(routes::companies::patch_company)
|
.patch(routes::companies::patch_company)
|
||||||
.delete(routes::companies::delete_company),
|
.delete(routes::companies::delete_company),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/companies/{id}/name",
|
||||||
|
axum::routing::patch(routes::companies::rename_company),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/companies/{id}/run",
|
"/api/companies/{id}/run",
|
||||||
post(routes::companies::run_company),
|
post(routes::companies::run_company),
|
||||||
@@ -379,6 +387,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/orgs/{id}",
|
"/api/orgs/{id}",
|
||||||
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
|
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/orgs/{id}/name",
|
||||||
|
axum::routing::patch(routes::orgs::rename_org),
|
||||||
|
)
|
||||||
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
||||||
.route(
|
.route(
|
||||||
"/api/research",
|
"/api/research",
|
||||||
|
|||||||
@@ -163,6 +163,25 @@ pub async fn patch_company(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/companies/{id}/name` — inline rename from the sidebar.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenameCompanyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
pub async fn rename_company(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<RenameCompanyRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let name = body.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
cm_db::repo::companies::rename_company(&state.pool, id, user.workspace_id, name).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/companies/{id}` — remove a company (its teams remain).
|
/// `DELETE /api/companies/{id}` — remove a company (its teams remain).
|
||||||
pub async fn delete_company(
|
pub async fn delete_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -152,6 +152,27 @@ pub struct OrgDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/orgs/{id}` — an org's graph + node→company bindings.
|
/// `GET /api/orgs/{id}` — an org's graph + node→company bindings.
|
||||||
|
/// `PATCH /api/orgs/{id}/name` — inline rename from the sidebar. Trims the
|
||||||
|
/// input and rejects empty; returns 204 on success, 404 if the id isn't
|
||||||
|
/// visible in the caller's workspace.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenameOrgRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
pub async fn rename_org(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<RenameOrgRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let name = body.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
cm_db::repo::orgs::rename_org(&state.pool, id, user.workspace_id, name).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/orgs/{id}` — remove the org (structural: companies survive, just
|
/// `DELETE /api/orgs/{id}` — remove the org (structural: companies survive, just
|
||||||
/// ungrouped from it).
|
/// ungrouped from it).
|
||||||
pub async fn delete_org(
|
pub async fn delete_org(
|
||||||
|
|||||||
@@ -365,6 +365,25 @@ pub async fn patch_team(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/teams/{id}/name` — inline rename from the sidebar.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenameTeamRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
pub async fn rename_team(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<RenameTeamRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let name = body.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
cm_db::repo::teams::rename_team(&state.pool, id, user.workspace_id, name).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/teams/{id}` — remove a team and its node→claw bindings (the claws
|
/// `DELETE /api/teams/{id}` — remove a team and its node→claw bindings (the claws
|
||||||
/// themselves remain in the workspace).
|
/// themselves remain in the workspace).
|
||||||
pub async fn delete_team(
|
pub async fn delete_team(
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ pub async fn set_topology(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a company and its node→team bindings (teams themselves remain).
|
/// Delete a company and its node→team bindings (teams themselves remain).
|
||||||
|
/// Rename a company in-place. Only the display name changes; the graph +
|
||||||
|
/// bindings are untouched.
|
||||||
|
pub async fn rename_company(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let res = sqlx::query("UPDATE companies SET name = $3 WHERE id = $1 AND workspace_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id.as_uuid())
|
||||||
|
.bind(name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_company(
|
pub async fn delete_company(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -149,6 +149,26 @@ pub async fn companies_for_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<OrgCom
|
|||||||
|
|
||||||
/// Delete an org. Structural only: the `org_companies` links are removed
|
/// Delete an org. Structural only: the `org_companies` links are removed
|
||||||
/// (cascade) so the companies survive, just ungrouped from this org.
|
/// (cascade) so the companies survive, just ungrouped from this org.
|
||||||
|
/// Rename an org in-place. The name is the only mutable identity field.
|
||||||
|
/// Returns NotFound when the id isn't visible in the caller's workspace.
|
||||||
|
pub async fn rename_org(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let res = sqlx::query("UPDATE orgs SET name = $3 WHERE id = $1 AND workspace_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id.as_uuid())
|
||||||
|
.bind(name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
|
pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
|
||||||
sqlx::query("DELETE FROM org_companies WHERE org_id = $1")
|
sqlx::query("DELETE FROM org_companies WHERE org_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -102,6 +102,26 @@ pub async fn set_topology(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a team and its node→claw bindings.
|
/// Delete a team and its node→claw bindings.
|
||||||
|
/// Rename a team in-place. Only the display name changes; the topology
|
||||||
|
/// graph + member bindings are untouched.
|
||||||
|
pub async fn rename_team(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let res = sqlx::query("UPDATE teams SET name = $3 WHERE id = $1 AND workspace_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id.as_uuid())
|
||||||
|
.bind(name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_team(
|
pub async fn delete_team(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -515,7 +515,23 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
}
|
}
|
||||||
return roots;
|
return roots;
|
||||||
};
|
};
|
||||||
const worldCanvasRoots: TreeItem[] = narrowRoots(worldRoots, worldSel);
|
// Strip synthetic UI-only containers ("my-workspace", "ws-teams",
|
||||||
|
// "ungrouped-co", "ungrouped-team") from the world viz seed. If they
|
||||||
|
// have children (real teams / agents), lift those children up to the
|
||||||
|
// parent's level so the viz shows the real workforce directly under
|
||||||
|
// the world root instead of parked inside fake orgs. Sidebar keeps the
|
||||||
|
// synthetic containers so orphaned agents still have a visible home
|
||||||
|
// until the wizard-driven migration lands.
|
||||||
|
const stripSynthetics = (roots: TreeItem[]): TreeItem[] => {
|
||||||
|
const walk = (items: TreeItem[]): TreeItem[] =>
|
||||||
|
items.flatMap((n) =>
|
||||||
|
SYNTHETIC_TREE_IDS.has(n.id)
|
||||||
|
? walk(n.children ?? [])
|
||||||
|
: [{ ...n, children: walk(n.children ?? []) }],
|
||||||
|
);
|
||||||
|
return walk(roots);
|
||||||
|
};
|
||||||
|
const worldCanvasRoots: TreeItem[] = narrowRoots(stripSynthetics(worldRoots), worldSel);
|
||||||
// Both the World and Agents tiers now share the same collapsible org →
|
// Both the World and Agents tiers now share the same collapsible org →
|
||||||
// company → team → agent tree — a single pane onto the whole workforce.
|
// company → team → agent tree — a single pane onto the whole workforce.
|
||||||
// `selectLevel` below still constrains selection to agents on the Agents
|
// `selectLevel` below still constrains selection to agents on the Agents
|
||||||
@@ -677,13 +693,39 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
|
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
|
||||||
<StructureTree roots={treeRoots} activeId={treeActiveId} autoExpand={treeAutoExpand} onSelectNode={onTreeSelect} selectMode={selectMode} selectLevel={"*"} selectedIds={selectedAgents} onToggleSelect={(id) => {
|
<StructureTree
|
||||||
// Synthetic UI-only containers ("my-workspace", "ws-teams",
|
roots={treeRoots}
|
||||||
// "ungrouped-co", "ungrouped-team") aren't real DB rows — the
|
activeId={treeActiveId}
|
||||||
// backend would 422 on the non-UUID id. Silently ignore taps.
|
autoExpand={treeAutoExpand}
|
||||||
if (SYNTHETIC_TREE_IDS.has(id)) return;
|
onSelectNode={onTreeSelect}
|
||||||
setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; });
|
selectMode={selectMode}
|
||||||
}} />
|
selectLevel={"*"}
|
||||||
|
selectedIds={selectedAgents}
|
||||||
|
onToggleSelect={(id) => {
|
||||||
|
// Synthetic UI-only containers ("my-workspace", "ws-teams",
|
||||||
|
// "ungrouped-co", "ungrouped-team") aren't real DB rows — the
|
||||||
|
// backend would 422 on the non-UUID id. Silently ignore taps.
|
||||||
|
if (SYNTHETIC_TREE_IDS.has(id)) return;
|
||||||
|
setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; });
|
||||||
|
}}
|
||||||
|
// Only real (non-synthetic, non-claw) nodes accept an inline
|
||||||
|
// rename. Synthetic scaffolding gets swapped for real rows in the
|
||||||
|
// next commit (wizard auto-materialize + migration dialog).
|
||||||
|
canRename={(it) => it.level !== "claw" && !SYNTHETIC_TREE_IDS.has(it.id)}
|
||||||
|
onRename={async (id, level, newLabel) => {
|
||||||
|
const path = level === "org" ? "orgs" : level === "company" ? "companies" : level === "team" ? "teams" : null;
|
||||||
|
if (!path) return;
|
||||||
|
const res = await fetch(`/api/${path}/${id}/name`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newLabel }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`PATCH /api/${path}/${id}/name → ${res.status}`);
|
||||||
|
// Force a re-fetch of the workspace tree so the new label lands
|
||||||
|
// everywhere (sidebar + viz + breadcrumbs).
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
|
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// clicking a claw opens that claw. Built to stay legible even when a whole org
|
// clicking a claw opens that claw. Built to stay legible even when a whole org
|
||||||
// of claws is expanded (indented rows, scrollable).
|
// of claws is expanded (indented rows, scrollable).
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
|
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ function levelIcon(item: TreeItem, active: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeRow({
|
function TreeRow({
|
||||||
item, depth, expanded, toggle, activeId, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect,
|
item, depth, expanded, toggle, activeId, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect, onRename, canRename,
|
||||||
}: {
|
}: {
|
||||||
item: TreeItem;
|
item: TreeItem;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -76,6 +76,8 @@ function TreeRow({
|
|||||||
selectLevel?: string;
|
selectLevel?: string;
|
||||||
selectedIds?: Set<string>;
|
selectedIds?: Set<string>;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
|
onRename?: (id: string, level: TreeLevel, newLabel: string) => Promise<void>;
|
||||||
|
canRename?: (item: TreeItem) => boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasChildren = !!item.children?.length;
|
const hasChildren = !!item.children?.length;
|
||||||
const isOpen = expanded.has(item.id);
|
const isOpen = expanded.has(item.id);
|
||||||
@@ -83,11 +85,42 @@ function TreeRow({
|
|||||||
// selectLevel "*" → any node is selectable (World tree spans all levels).
|
// selectLevel "*" → any node is selectable (World tree spans all levels).
|
||||||
const selectable = !!selectMode && (selectLevel === "*" ? true : item.level === (selectLevel ?? "claw"));
|
const selectable = !!selectMode && (selectLevel === "*" ? true : item.level === (selectLevel ?? "claw"));
|
||||||
const checked = selectedIds?.has(item.id) ?? false;
|
const checked = selectedIds?.has(item.id) ?? false;
|
||||||
|
const renamable =
|
||||||
|
!selectable && !!onRename && (canRename ? canRename(item) : item.level !== "claw");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(item.label);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
// Focus + select-all in the next tick so the user can just type over.
|
||||||
|
// Reset of `draft` happens in the click handler that flips `editing`,
|
||||||
|
// not here — avoids react-hooks/set-state-in-effect.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) queueMicrotask(() => inputRef.current?.select());
|
||||||
|
}, [editing]);
|
||||||
|
function enterEdit() {
|
||||||
|
setDraft(item.label);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const next = draft.trim();
|
||||||
|
if (!next || next === item.label) {
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onRename?.(item.id, item.level, next);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
onClick={() => { if (selectable) { onToggleSelect?.(item.id); return; } if (hasChildren) toggle(item.id); onSelectNode(item); }}
|
onClick={() => { if (editing) return; if (selectable) { onToggleSelect?.(item.id); return; } if (hasChildren) toggle(item.id); onSelectNode(item); }}
|
||||||
style={{ position: "relative", display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", paddingLeft: 8 + depth * 14, borderRadius: 8, cursor: "pointer", background: checked ? "rgba(255,111,97,.16)" : active ? "rgba(255,111,97,.12)" : "transparent" }}
|
style={{ position: "relative", display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", paddingLeft: 8 + depth * 14, borderRadius: 8, cursor: editing ? "text" : "pointer", background: checked ? "rgba(255,111,97,.16)" : active ? "rgba(255,111,97,.12)" : "transparent" }}
|
||||||
>
|
>
|
||||||
{active && !selectable ? <span style={{ position: "absolute", left: 0, top: 6, bottom: 6, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
|
{active && !selectable ? <span style={{ position: "absolute", left: 0, top: 6, bottom: 6, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
|
||||||
{selectable ? (
|
{selectable ? (
|
||||||
@@ -102,14 +135,35 @@ function TreeRow({
|
|||||||
)}
|
)}
|
||||||
{levelIcon(item, active)}
|
{levelIcon(item, active)}
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span style={{ display: "block", fontSize: 12.5, fontWeight: active ? 600 : 500, color: active ? "#fff" : "#dcdce2", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.label}</span>
|
{editing ? (
|
||||||
{item.meta ? <span style={{ display: "block", fontFamily: mono, fontSize: 9.5, color: active ? "#ff8a7a" : "#6a6a72", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.meta}</span> : null}
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
disabled={saving}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); void commit(); }
|
||||||
|
else if (e.key === "Escape") { e.preventDefault(); setEditing(false); }
|
||||||
|
}}
|
||||||
|
onBlur={() => { void commit(); }}
|
||||||
|
style={{ display: "block", width: "100%", fontSize: 12.5, fontWeight: 600, color: "#fff", background: "rgba(255,255,255,.06)", border: "1px solid rgba(255,111,97,.5)", borderRadius: 5, padding: "1px 5px", outline: "none" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={renamable ? (e) => { e.stopPropagation(); enterEdit(); } : undefined}
|
||||||
|
title={renamable ? "Click to rename" : undefined}
|
||||||
|
style={{ display: "block", fontSize: 12.5, fontWeight: active ? 600 : 500, color: active ? "#fff" : "#dcdce2", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: renamable ? "text" : "inherit" }}
|
||||||
|
>{item.label}</span>
|
||||||
|
)}
|
||||||
|
{item.meta && !editing ? <span style={{ display: "block", fontFamily: mono, fontSize: 9.5, color: active ? "#ff8a7a" : "#6a6a72", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.meta}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
{item.level !== "claw" && item.status ? <span style={{ flex: "none", width: 6, height: 6, borderRadius: "50%", background: statusColor(item.status) }} /> : null}
|
{item.level !== "claw" && item.status ? <span style={{ flex: "none", width: 6, height: 6, borderRadius: "50%", background: statusColor(item.status) }} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && isOpen
|
{hasChildren && isOpen
|
||||||
? item.children!.map((c) => (
|
? item.children!.map((c) => (
|
||||||
<TreeRow key={c.id} item={c} depth={depth + 1} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} />
|
<TreeRow key={c.id} item={c} depth={depth + 1} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} onRename={onRename} canRename={canRename} />
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
@@ -117,7 +171,7 @@ function TreeRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StructureTree({
|
export function StructureTree({
|
||||||
roots, activeId, autoExpand, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect,
|
roots, activeId, autoExpand, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect, onRename, canRename,
|
||||||
}: {
|
}: {
|
||||||
roots: TreeItem[];
|
roots: TreeItem[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
@@ -127,6 +181,13 @@ export function StructureTree({
|
|||||||
selectLevel?: string;
|
selectLevel?: string;
|
||||||
selectedIds?: Set<string>;
|
selectedIds?: Set<string>;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
|
/** Called when the user commits a new label. Awaited so the row can
|
||||||
|
* show a saving state; throw to signal failure (the row will still
|
||||||
|
* exit edit mode — parent should re-render with the DB-truth label). */
|
||||||
|
onRename?: (id: string, level: TreeLevel, newLabel: string) => Promise<void>;
|
||||||
|
/** Gate for which nodes accept an inline rename. Defaults to
|
||||||
|
* "everything except claw" — synthetic ids should be filtered here. */
|
||||||
|
canRename?: (item: TreeItem) => boolean;
|
||||||
}) {
|
}) {
|
||||||
const autoKey = autoExpand.join("|");
|
const autoKey = autoExpand.join("|");
|
||||||
const [seenAuto, setSeenAuto] = useState<string>(autoKey);
|
const [seenAuto, setSeenAuto] = useState<string>(autoKey);
|
||||||
@@ -148,7 +209,7 @@ export function StructureTree({
|
|||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 8 }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: 8 }}>
|
||||||
{roots.map((r) => (
|
{roots.map((r) => (
|
||||||
<TreeRow key={r.id} item={r} depth={0} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} />
|
<TreeRow key={r.id} item={r} depth={0} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} onRename={onRename} canRename={canRename} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user