jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

253 lines
12 KiB
TypeScript

/** Accessible, default-off Projects and Conversations workspace for HUX-03. */
import { Folder, GitBranch, Paperclip, Pin, Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { attachmentViews, branchLineage, projectsEnabled } from "./model.ts";
import type {
ArtifactSummary,
Conversation,
Project,
} from "./types.ts";
interface OrganizationWorkspaceProps {
flags?: Iterable<string>;
projects: readonly Project[];
conversations: readonly Conversation[];
artifacts?: readonly ArtifactSummary[];
searchResults?: readonly Conversation[];
nextSearchCursor?: string | null;
busy?: boolean;
error?: string | null;
onSearch?: (query: string, cursor: string | null) => void;
onRenameProject?: (project: Project, name: string) => void;
onRenameConversation?: (conversation: Conversation, title: string) => void;
onUpdateProjectTags?: (project: Project, tags: string[]) => void;
onUpdateConversationTags?: (conversation: Conversation, tags: string[]) => void;
onMoveConversation?: (conversation: Conversation, projectId: string | null) => void;
onToggleProjectPin?: (project: Project) => void;
onToggleConversationPin?: (conversation: Conversation) => void;
}
function ordered<T extends {pinned: boolean; updatedAt: string}>(items: readonly T[]): T[] {
return [...items].sort((left, right) => Number(right.pinned) - Number(left.pinned) ||
right.updatedAt.localeCompare(left.updatedAt));
}
function Tags({values}: {values: readonly string[]}) {
if (!values.length) return null;
return <ul className="hux-org-tags" aria-label="Tags">
{values.map((tag) => <li key={tag}>{tag}</li>)}
</ul>;
}
function EditableTitle({label, value, maxLength, onSave}: {
label: string;
value: string;
maxLength: number;
onSave?: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!onSave) return <span>{value}</span>;
if (!editing) return <button className="hux-org-title-button" type="button"
onClick={() => { setDraft(value); setEditing(true); }} aria-label={`${label}: ${value}. Rename`}>
Rename
</button>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const next = draft.trim();
if (next && next !== value) onSave(next);
setEditing(false);
}}>
<label><span className="sr-only">{label}</span>
<input value={draft} maxLength={maxLength} autoFocus onChange={(event) => setDraft(event.target.value)} />
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function EditableTags({label, values, onSave}: {
label: string;
values: readonly string[];
onSave?: (values: string[]) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(values.join(", "));
const [invalid, setInvalid] = useState(false);
if (!onSave) return <Tags values={values} />;
if (!editing) return <div><Tags values={values} /><button type="button"
onClick={() => { setDraft(values.join(", ")); setInvalid(false); setEditing(true); }}>
Edit tags
</button></div>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const tags = draft.split(",").map((tag) => tag.trim()).filter(Boolean);
const valid = tags.length <= 32 && new Set(tags).size === tags.length &&
tags.every((tag) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(tag));
setInvalid(!valid);
if (valid) { onSave(tags); setEditing(false); }
}}>
<label><span>{label}</span>
<input value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
placeholder="planning, research" />
</label>
{invalid && <span role="alert">Use up to 32 lowercase tags separated by commas.</span>}
<button type="submit">Save tags</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function ProjectList({projects, selectedId, onSelect, onRename, onTags, onPin}: {
projects: readonly Project[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onRename?: OrganizationWorkspaceProps["onRenameProject"];
onTags?: OrganizationWorkspaceProps["onUpdateProjectTags"];
onPin?: OrganizationWorkspaceProps["onToggleProjectPin"];
}) {
return <nav className="hux-org-projects" aria-label="Project folders">
<h2><Folder aria-hidden /> Projects</h2>
<ul>
<li><button type="button" aria-current={selectedId === null ? "page" : undefined}
onClick={() => onSelect(null)}>All conversations</button></li>
{ordered(projects).map((project) => <li key={project.id} data-pinned={project.pinned}>
<button className="hux-org-folder" type="button"
aria-current={selectedId === project.id ? "page" : undefined}
onClick={() => onSelect(project.id)}>{project.name}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${project.pinned ? "Unpin" : "Pin"} ${project.name}`}
aria-pressed={project.pinned} onClick={() => onPin(project)}><Pin aria-hidden /></button>}
{onRename && <EditableTitle label="Project name" value={project.name} maxLength={120}
onSave={(name) => onRename(project, name)} />}
<EditableTags label="Project tags" values={project.tags}
onSave={onTags ? (tags) => onTags(project, tags) : undefined} />
</li>)}
</ul>
</nav>;
}
function Lineage({conversation, conversations}: {
conversation: Conversation;
conversations: readonly Conversation[];
}) {
const nodes = branchLineage(conversation.id, conversations);
return <section className="hux-org-lineage" aria-labelledby="hux-org-lineage-title">
<h3 id="hux-org-lineage-title"><GitBranch aria-hidden /> Branch lineage</h3>
<ol>
{nodes.map((node) => <li key={`${node.conversationId}-${node.depth}`}
data-missing={node.missing} data-cycle={node.cycle}>
<span>{node.title}</span>
{node.branchPointMessageId && <small>branched at message {node.branchPointMessageId}</small>}
</li>)}
</ol>
</section>;
}
function Attachments({conversation, artifacts}: {
conversation: Conversation;
artifacts: readonly ArtifactSummary[];
}) {
const items = attachmentViews(conversation, artifacts);
return <section className="hux-org-attachments" aria-labelledby="hux-org-attachments-title">
<h3 id="hux-org-attachments-title"><Paperclip aria-hidden /> Attached artifacts</h3>
{items.length ? <ul>{items.map((item) => <li key={item.id} data-missing={item.missing}>
<span>{item.title}</span><small>{item.missing ? "Still attached; details unavailable" : item.kind}</small>
</li>)}</ul> : <p>No artifacts attached.</p>}
</section>;
}
function ConversationList({items, projects, selected, onSelect, onRename, onTags, onMove, onPin}: {
items: readonly Conversation[];
projects: readonly Project[];
selected: string | null;
onSelect: (id: string) => void;
onRename?: OrganizationWorkspaceProps["onRenameConversation"];
onTags?: OrganizationWorkspaceProps["onUpdateConversationTags"];
onMove?: OrganizationWorkspaceProps["onMoveConversation"];
onPin?: OrganizationWorkspaceProps["onToggleConversationPin"];
}) {
return <ul className="hux-org-conversations" aria-label="Conversations">
{ordered(items).map((conversation) => <li key={conversation.id}
data-selected={selected === conversation.id} data-pinned={conversation.pinned}>
<div className="hux-org-conversation-title">
<button type="button" onClick={() => onSelect(conversation.id)}
aria-pressed={selected === conversation.id}>{conversation.title}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${conversation.pinned ? "Unpin" : "Pin"} ${conversation.title}`}
aria-pressed={conversation.pinned} onClick={() => onPin(conversation)}><Pin aria-hidden /></button>}
</div>
{onRename && <EditableTitle label="Conversation title" value={conversation.title} maxLength={200}
onSave={(title) => onRename(conversation, title)} />}
<EditableTags label="Conversation tags" values={conversation.tags}
onSave={onTags ? (tags) => onTags(conversation, tags) : undefined} />
<a className="hux-org-resume" href={`/session/${encodeURIComponent(conversation.id)}`}>
Resume conversation
</a>
{onMove && <label className="hux-org-move">Move to
<select value={conversation.projectId || ""}
onChange={(event) => onMove(conversation, event.target.value || null)}>
<option value="">No project</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>}
</li>)}
</ul>;
}
/** Render nothing unless the negotiated hux.foundation and hux.projects flags are enabled. */
export function OrganizationWorkspace(props: OrganizationWorkspaceProps) {
const [projectId, setProjectId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const enabled = projectsEnabled(props.flags);
const baseItems = props.searchResults || props.conversations;
const items = useMemo(() => projectId === null ? baseItems :
baseItems.filter((item) => item.projectId === projectId), [baseItems, projectId]);
const selected = props.conversations.find((item) => item.id === selectedId) ||
items[0] || null;
if (!enabled) return null;
function submitSearch(event: FormEvent) {
event.preventDefault();
const value = query.trim();
if (value) props.onSearch?.(value, null);
}
return <section className="hux-organization" aria-label="Projects and conversations" aria-busy={props.busy}>
<ProjectList projects={props.projects} selectedId={projectId} onSelect={setProjectId}
onRename={props.onRenameProject} onTags={props.onUpdateProjectTags}
onPin={props.onToggleProjectPin} />
<main className="hux-org-main">
<header><h2>Conversations</h2>
<form role="search" onSubmit={submitSearch}>
<label htmlFor="hux-org-search">Search conversations, messages, projects, and artifacts</label>
<div><Search aria-hidden /><input id="hux-org-search" type="search" maxLength={200}
value={query} onChange={(event) => setQuery(event.target.value)} />
<button type="submit" disabled={!query.trim() || props.busy}>Search</button></div>
</form>
</header>
{props.error && <p className="hux-org-error" role="alert">{props.error}</p>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Loading conversations" : `${items.length} conversations shown`}
</p>
<ConversationList items={items} projects={props.projects} selected={selected?.id || null}
onSelect={setSelectedId} onRename={props.onRenameConversation}
onTags={props.onUpdateConversationTags} onMove={props.onMoveConversation}
onPin={props.onToggleConversationPin} />
{!items.length && <p className="hux-org-empty">No matching conversations.</p>}
{props.nextSearchCursor && props.onSearch && <button className="hux-org-more" type="button"
disabled={props.busy} onClick={() => props.onSearch?.(query.trim(), props.nextSearchCursor || null)}>
Load more results
</button>}
</main>
<aside className="hux-org-details" aria-label="Conversation details">
{selected ? <><Lineage conversation={selected} conversations={props.conversations} />
<Attachments conversation={selected} artifacts={props.artifacts || []} /></> :
<p>Select a conversation to inspect its lineage and attachments.</p>}
</aside>
</section>;
}