forked from molecule-ai/molecule-core
Merge pull request #1989 from Molecule-AI/fix/canvas-a11y-final
fix(canvas/a11y): type=button campaign + aria fixes (batch 1-3)
This commit is contained in:
commit
6b62391e5d
@ -138,6 +138,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
@ -152,6 +153,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
|
||||
aria-label="Refresh audit trail"
|
||||
@ -190,6 +192,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
{cursor && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
|
||||
|
||||
@ -91,6 +91,7 @@ export function BatchActionBar() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("restart")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||
@ -100,6 +101,7 @@ export function BatchActionBar() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("pause")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
@ -109,6 +111,7 @@ export function BatchActionBar() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
@ -121,6 +124,7 @@ export function BatchActionBar() {
|
||||
|
||||
{/* Deselect */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
|
||||
@ -108,6 +108,7 @@ export function BundleDropZone() {
|
||||
{/* Keyboard-accessible import button — visible on focus or hover so
|
||||
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Import bundle file"
|
||||
aria-controls="bundle-file-input"
|
||||
|
||||
@ -99,6 +99,7 @@ export function CommunicationOverlay() {
|
||||
if (!visible || comms.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
aria-label="Show communications panel"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
@ -115,6 +116,7 @@ export function CommunicationOverlay() {
|
||||
<span aria-hidden="true">↗↙ </span>Communications ({comms.length})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(false)}
|
||||
aria-label="Close communications panel"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
|
||||
@ -121,6 +121,7 @@ export function ConfirmDialog({
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
{!singleButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
@ -128,6 +129,7 @@ export function ConfirmDialog({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
>
|
||||
|
||||
@ -109,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@ -146,6 +147,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||
{output && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output);
|
||||
@ -159,6 +161,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
|
||||
@ -308,6 +308,7 @@ export function ContextMenu() {
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
role="menuitem"
|
||||
onClick={item.action}
|
||||
|
||||
@ -112,6 +112,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation trace"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
@ -283,6 +284,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
|
||||
@ -211,7 +211,7 @@ export function CreateWorkspaceButton() {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@ -284,6 +284,7 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.value}
|
||||
ref={(el) => { radioRefs.current[idx] = el; }}
|
||||
role="radio"
|
||||
@ -432,11 +433,12 @@ export function CreateWorkspaceButton() {
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
<button type="button" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
|
||||
@ -143,12 +143,14 @@ export function DeleteCascadeConfirmDialog({
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
|
||||
@ -110,6 +110,7 @@ export function EmptyState() {
|
||||
const tierColor = TIER_CONFIG[t.tier]?.border || TIER_CONFIG[1].border;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
@ -140,6 +141,7 @@ export function EmptyState() {
|
||||
|
||||
{/* Create blank */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={createBlank}
|
||||
disabled={!!deploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
|
||||
@ -63,6 +63,7 @@ export class ErrorBoundary extends React.Component<
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
@ -80,6 +81,7 @@ export class ErrorBoundary extends React.Component<
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
|
||||
@ -160,6 +160,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
type="button"
|
||||
key={scope}
|
||||
onClick={() => setActiveScope(scope)}
|
||||
aria-pressed={activeScope === scope}
|
||||
@ -201,6 +202,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
@ -240,6 +242,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
@ -273,6 +276,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
Try a different query or{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
@ -339,6 +343,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
@ -409,6 +414,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
|
||||
@ -556,6 +556,7 @@ function AllKeysModal({
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
@ -580,6 +581,7 @@ function AllKeysModal({
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
@ -589,12 +591,14 @@ function AllKeysModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
|
||||
@ -159,6 +159,7 @@ export function OnboardingWizard() {
|
||||
Step {currentStepIdx + 1} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Skip onboarding guide"
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
@ -178,6 +179,7 @@ export function OnboardingWizard() {
|
||||
{/* Action button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
>
|
||||
@ -191,6 +193,7 @@ export function OnboardingWizard() {
|
||||
</button>
|
||||
{step !== "done" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = STEPS[currentStepIdx + 1];
|
||||
if (next) setStep(next.id);
|
||||
|
||||
@ -284,6 +284,7 @@ export function ProvisioningTimeout({
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
|
||||
@ -291,6 +292,7 @@ export function ProvisioningTimeout({
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
|
||||
@ -298,6 +300,7 @@ export function ProvisioningTimeout({
|
||||
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
@ -323,12 +326,14 @@ export function ProvisioningTimeout({
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
|
||||
@ -132,6 +132,7 @@ export function SearchDialog() {
|
||||
) : (
|
||||
filtered.map((node, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={node.id}
|
||||
id={`search-result-${node.id}`}
|
||||
role="option"
|
||||
|
||||
@ -178,6 +178,7 @@ export function SidePanel() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectNode(null)}
|
||||
aria-label="Close workspace panel"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
|
||||
@ -221,6 +222,7 @@ export function SidePanel() {
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
role="tab"
|
||||
@ -246,6 +248,7 @@ export function SidePanel() {
|
||||
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
||||
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
|
||||
@ -159,6 +159,7 @@ export function OrgTemplatesSection() {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOrgs}
|
||||
aria-label="Refresh org templates"
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
@ -209,6 +210,7 @@ export function OrgTemplatesSection() {
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImport(o)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
@ -284,6 +286,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
@ -405,6 +408,7 @@ export function TemplatePalette() {
|
||||
<>
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
open
|
||||
@ -477,6 +481,7 @@ export function TemplatePalette() {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
@ -521,6 +526,7 @@ export function TemplatePalette() {
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTemplates}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
|
||||
>
|
||||
|
||||
@ -102,6 +102,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
|
||||
@ -63,6 +63,7 @@ export function Toaster() {
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
@ -90,6 +91,7 @@ export function Toaster() {
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
|
||||
@ -168,6 +168,7 @@ export function Toolbar() {
|
||||
{/* Stop All — visible when agents have active tasks */}
|
||||
{counts.activeTasks > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
@ -186,6 +187,7 @@ export function Toolbar() {
|
||||
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
|
||||
{needsRestartNodes.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
@ -208,6 +210,7 @@ export function Toolbar() {
|
||||
|
||||
{/* A2A topology overlay toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowA2AEdges(!showA2AEdges)}
|
||||
aria-pressed={showA2AEdges}
|
||||
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
|
||||
@ -241,6 +244,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedNodeId) {
|
||||
setPanelTab("audit");
|
||||
@ -268,6 +272,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Search shortcut */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||
aria-label="Search workspaces"
|
||||
title="Search (⌘K)"
|
||||
@ -282,6 +287,7 @@ export function Toolbar() {
|
||||
{/* Quick help */}
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
aria-expanded={helpOpen}
|
||||
@ -299,6 +305,7 @@ export function Toolbar() {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
@ -250,6 +250,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{/* Needs restart banner */}
|
||||
{data.needsRestart && !data.currentTask && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||
@ -317,6 +318,197 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Maximum nesting depth for recursive TeamMemberChip rendering — prevents
|
||||
* infinite recursion on circular parentId references and keeps the UI readable. */
|
||||
const MAX_NESTING_DEPTH = 3;
|
||||
|
||||
/** Subscribes to allNodes only when children exist — isolates re-renders from parent */
|
||||
function EmbeddedTeam({ members, depth, onSelect, onExtract }: {
|
||||
members: Node<WorkspaceNodeData>[];
|
||||
depth: number;
|
||||
onSelect: (id: string) => void;
|
||||
onExtract: (id: string) => void;
|
||||
}) {
|
||||
const allNodes = useCanvasStore((s) => s.nodes);
|
||||
// Use grid layout at depth 0 when there are multiple members (departments side-by-side)
|
||||
const useGrid = depth === 0 && members.length >= 2;
|
||||
return (
|
||||
<div className="mt-2 pt-2 border-t border-zinc-700/30">
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1.5">Team Members</div>
|
||||
<div className={useGrid
|
||||
? "grid grid-cols-2 gap-1.5 lg:grid-cols-3"
|
||||
: "space-y-1.5"
|
||||
}>
|
||||
{members.map((child) => (
|
||||
<TeamMemberChip key={child.id} node={child} allNodes={allNodes} depth={depth} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Recursive mini-card — mirrors parent card layout at smaller scale */
|
||||
function TeamMemberChip({
|
||||
node,
|
||||
allNodes,
|
||||
depth,
|
||||
onSelect,
|
||||
onExtract,
|
||||
}: {
|
||||
node: Node<WorkspaceNodeData>;
|
||||
allNodes: Node<WorkspaceNodeData>[];
|
||||
depth: number;
|
||||
onSelect: (id: string) => void;
|
||||
onExtract: (id: string) => void;
|
||||
}) {
|
||||
const { data } = node;
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
|
||||
const isOnline = data.status === "online";
|
||||
const skills = getSkillNames(data.agentCard);
|
||||
|
||||
const subChildren = useMemo(
|
||||
() => allNodes.filter((n) => n.data.parentId === node.id),
|
||||
[allNodes, node.id]
|
||||
);
|
||||
const hasSubChildren = subChildren.length > 0;
|
||||
const descendantCount = useMemo(
|
||||
() => hasSubChildren ? countDescendants(node.id, allNodes) : 0,
|
||||
[allNodes, node.id, hasSubChildren]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${data.name}`}
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data });
|
||||
}}
|
||||
>
|
||||
{/* Status gradient bar */}
|
||||
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
|
||||
<div className="relative px-2 py-1.5">
|
||||
{/* Header: name + badges + extract */}
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
|
||||
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[7px] font-mono px-1 py-0.5 rounded ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Extract ${data.name} from team`}
|
||||
title={`Extract ${data.name} from team`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
|
||||
>
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mb-1">
|
||||
{skills.slice(0, 3).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={`text-[10px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 3 && (
|
||||
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status + active tasks row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{data.status !== "online" ? (
|
||||
<span className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-300" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
) : <div />}
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300 tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current task banner for sub-agents */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recursive sub-children rendered inside this card */}
|
||||
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
|
||||
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
|
||||
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
|
||||
{subChildren.map((sub) => (
|
||||
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
|
||||
if (!agentCard) return [];
|
||||
|
||||
@ -125,6 +125,7 @@ export function OrgTokensTab() {
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
placeholder="Label (e.g. zapier, my-ci)"
|
||||
maxLength={100}
|
||||
aria-label="Organization API key label"
|
||||
className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600"
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -62,6 +62,7 @@ function GearIcon() {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
|
||||
@ -49,14 +49,17 @@ export const DEFAULT_CONFIG: ConfigData = {
|
||||
};
|
||||
|
||||
export function TextInput({ label, value, onChange, placeholder, mono }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
|
||||
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
className={`w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 ${mono ? "font-mono" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
@ -64,15 +67,18 @@ export function TextInput({ label, value, onChange, placeholder, mono }: { label
|
||||
}
|
||||
|
||||
export function NumberInput({ label, value, onChange, min, max }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
aria-label={label}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
@ -89,10 +95,11 @@ export function Toggle({ label, checked, onChange }: { label: string; checked: b
|
||||
}
|
||||
|
||||
export function TagList({ label, values, onChange, placeholder }: { label: string; values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
|
||||
const id = `taglist-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 font-mono">
|
||||
@ -102,6 +109,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@ -112,6 +120,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type and press Enter"}
|
||||
aria-label={label}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-[10px] text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -30,7 +30,7 @@ export function RevealToggle({
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
@ -39,7 +39,7 @@ function EyeIcon() {
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user