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
253 lines
12 KiB
TypeScript
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>;
|
|
}
|