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">
|
<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) => (
|
{FILTERS.map((f) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => setFilter(f.id)}
|
onClick={() => setFilter(f.id)}
|
||||||
aria-pressed={filter === f.id}
|
aria-pressed={filter === f.id}
|
||||||
@ -152,6 +153,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
))}
|
))}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadEntries}
|
onClick={loadEntries}
|
||||||
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
|
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"
|
aria-label="Refresh audit trail"
|
||||||
@ -190,6 +192,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
{cursor && (
|
{cursor && (
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={loadingMore}
|
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"
|
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 */}
|
{/* Action buttons */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setPending("restart")}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setPending("pause")}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setPending("delete")}
|
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"
|
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 */}
|
{/* Deselect */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
aria-label="Clear selection"
|
aria-label="Clear selection"
|
||||||
|
|||||||
@ -108,6 +108,7 @@ export function BundleDropZone() {
|
|||||||
{/* Keyboard-accessible import button — visible on focus or hover so
|
{/* 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) */}
|
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
aria-label="Import bundle file"
|
aria-label="Import bundle file"
|
||||||
aria-controls="bundle-file-input"
|
aria-controls="bundle-file-input"
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export function CommunicationOverlay() {
|
|||||||
if (!visible || comms.length === 0) {
|
if (!visible || comms.length === 0) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setVisible(true)}
|
onClick={() => setVisible(true)}
|
||||||
aria-label="Show communications panel"
|
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"
|
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})
|
<span aria-hidden="true">↗↙ </span>Communications ({comms.length})
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setVisible(false)}
|
onClick={() => setVisible(false)}
|
||||||
aria-label="Close communications panel"
|
aria-label="Close communications panel"
|
||||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
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">
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||||
{!singleButton && (
|
{!singleButton && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onCancel}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
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">
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||||
{output && (
|
{output && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(output);
|
navigator.clipboard.writeText(output);
|
||||||
@ -159,6 +161,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
|
|||||||
@ -112,6 +112,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
</div>
|
</div>
|
||||||
<Dialog.Close asChild>
|
<Dialog.Close asChild>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
aria-label="Close conversation trace"
|
aria-label="Close conversation trace"
|
||||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
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">
|
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||||
<Dialog.Close asChild>
|
<Dialog.Close asChild>
|
||||||
<button
|
<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"
|
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
|
|||||||
@ -211,7 +211,7 @@ export function CreateWorkspaceButton() {
|
|||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
<Dialog.Trigger asChild>
|
<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
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@ -284,6 +284,7 @@ export function CreateWorkspaceButton() {
|
|||||||
</div>
|
</div>
|
||||||
{TIERS.map((t, idx) => (
|
{TIERS.map((t, idx) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={t.value}
|
key={t.value}
|
||||||
ref={(el) => { radioRefs.current[idx] = el; }}
|
ref={(el) => { radioRefs.current[idx] = el; }}
|
||||||
role="radio"
|
role="radio"
|
||||||
@ -432,11 +433,12 @@ export function CreateWorkspaceButton() {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-2.5 mt-6">
|
<div className="flex justify-end gap-2.5 mt-6">
|
||||||
<Dialog.Close asChild>
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={creating}
|
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"
|
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">
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onCancel}
|
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"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={!checked}
|
disabled={!checked}
|
||||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
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;
|
const tierColor = TIER_CONFIG[t.tier]?.border || TIER_CONFIG[1].border;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => deploy(t)}
|
onClick={() => deploy(t)}
|
||||||
disabled={!!deploying}
|
disabled={!!deploying}
|
||||||
@ -140,6 +141,7 @@ export function EmptyState() {
|
|||||||
|
|
||||||
{/* Create blank */}
|
{/* Create blank */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={createBlank}
|
onClick={createBlank}
|
||||||
disabled={!!deploying}
|
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"
|
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"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
@ -80,6 +81,7 @@ export class ErrorBoundary extends React.Component<
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={this.handleReload}
|
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"
|
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">
|
<div className="flex items-center gap-1">
|
||||||
{SCOPES.map((scope) => (
|
{SCOPES.map((scope) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={scope}
|
key={scope}
|
||||||
onClick={() => setActiveScope(scope)}
|
onClick={() => setActiveScope(scope)}
|
||||||
aria-pressed={activeScope === scope}
|
aria-pressed={activeScope === scope}
|
||||||
@ -201,6 +202,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setDebouncedQuery("");
|
setDebouncedQuery("");
|
||||||
@ -240,6 +242,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
: `${entries.length} memories`}
|
: `${entries.length} memories`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadEntries}
|
onClick={loadEntries}
|
||||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||||
aria-label="Refresh memories"
|
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">
|
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||||
Try a different query or{" "}
|
Try a different query or{" "}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setDebouncedQuery("");
|
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">
|
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<button
|
<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"
|
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)}
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
@ -409,6 +414,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
|||||||
Created: {new Date(entry.created_at).toLocaleString()}
|
Created: {new Date(entry.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
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"
|
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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleSaveKey(index)}
|
onClick={() => handleSaveKey(index)}
|
||||||
disabled={!entry.value.trim() || entry.saving}
|
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"
|
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>
|
<div>
|
||||||
{onOpenSettings && (
|
{onOpenSettings && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
@ -589,12 +591,14 @@ function AllKeysModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onCancel}
|
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"
|
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
|
Cancel Deploy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAddKeysAndDeploy}
|
onClick={handleAddKeysAndDeploy}
|
||||||
disabled={!allSaved || anySaving}
|
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"
|
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}
|
Step {currentStepIdx + 1} of {STEPS.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
aria-label="Skip onboarding guide"
|
aria-label="Skip onboarding guide"
|
||||||
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
@ -178,6 +179,7 @@ export function OnboardingWizard() {
|
|||||||
{/* Action button */}
|
{/* Action button */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAction}
|
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"
|
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>
|
</button>
|
||||||
{step !== "done" && (
|
{step !== "done" && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = STEPS[currentStepIdx + 1];
|
const next = STEPS[currentStepIdx + 1];
|
||||||
if (next) setStep(next.id);
|
if (next) setStep(next.id);
|
||||||
|
|||||||
@ -284,6 +284,7 @@ export function ProvisioningTimeout({
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2 mt-2.5">
|
<div className="flex items-center gap-2 mt-2.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleRetry(entry.workspaceId)}
|
onClick={() => handleRetry(entry.workspaceId)}
|
||||||
disabled={isRetrying || isCancelling || retryCooldown.has(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"
|
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"}
|
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||||
disabled={isRetrying || isCancelling}
|
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"
|
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"}
|
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||||
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
|
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>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setConfirmingCancel(null)}
|
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"
|
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
|
Keep
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleCancelConfirm}
|
onClick={handleCancelConfirm}
|
||||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
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) => (
|
filtered.map((node, index) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={node.id}
|
key={node.id}
|
||||||
id={`search-result-${node.id}`}
|
id={`search-result-${node.id}`}
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@ -178,6 +178,7 @@ export function SidePanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => selectNode(null)}
|
onClick={() => selectNode(null)}
|
||||||
aria-label="Close workspace panel"
|
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"
|
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) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
id={`tab-${tab.id}`}
|
id={`tab-${tab.id}`}
|
||||||
role="tab"
|
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">
|
<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>
|
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export function OrgTemplatesSection() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadOrgs}
|
onClick={loadOrgs}
|
||||||
aria-label="Refresh org templates"
|
aria-label="Refresh org templates"
|
||||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||||
@ -209,6 +210,7 @@ export function OrgTemplatesSection() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleImport(o)}
|
onClick={() => handleImport(o)}
|
||||||
disabled={isImporting}
|
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"
|
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)}
|
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={importing}
|
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"
|
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 */}
|
{/* Toggle button */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||||
open
|
open
|
||||||
@ -477,6 +481,7 @@ export function TemplatePalette() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => handleDeploy(t)}
|
onClick={() => handleDeploy(t)}
|
||||||
disabled={isDeploying}
|
disabled={isDeploying}
|
||||||
@ -521,6 +526,7 @@ export function TemplatePalette() {
|
|||||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||||
<ImportAgentButton onImported={loadTemplates} />
|
<ImportAgentButton onImported={loadTemplates} />
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadTemplates}
|
onClick={loadTemplates}
|
||||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
|
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>}
|
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={accept}
|
onClick={accept}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
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)}>
|
<div key={toast.id} className={toastCls(toast.type)}>
|
||||||
<span>{toast.message}</span>
|
<span>{toast.message}</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => dismiss(toast.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
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)}>
|
<div key={toast.id} className={toastCls(toast.type)}>
|
||||||
<span>{toast.message}</span>
|
<span>{toast.message}</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => dismiss(toast.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
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 */}
|
{/* Stop All — visible when agents have active tasks */}
|
||||||
{counts.activeTasks > 0 && (
|
{counts.activeTasks > 0 && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={stopAll}
|
onClick={stopAll}
|
||||||
disabled={stopping}
|
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"
|
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 */}
|
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
|
||||||
{needsRestartNodes.length > 0 && (
|
{needsRestartNodes.length > 0 && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setRestartConfirmOpen(true)}
|
onClick={() => setRestartConfirmOpen(true)}
|
||||||
disabled={restartingAll}
|
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"
|
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 */}
|
{/* A2A topology overlay toggle */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowA2AEdges(!showA2AEdges)}
|
onClick={() => setShowA2AEdges(!showA2AEdges)}
|
||||||
aria-pressed={showA2AEdges}
|
aria-pressed={showA2AEdges}
|
||||||
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
|
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 */}
|
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedNodeId) {
|
if (selectedNodeId) {
|
||||||
setPanelTab("audit");
|
setPanelTab("audit");
|
||||||
@ -268,6 +272,7 @@ export function Toolbar() {
|
|||||||
|
|
||||||
{/* Search shortcut */}
|
{/* Search shortcut */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||||
aria-label="Search workspaces"
|
aria-label="Search workspaces"
|
||||||
title="Search (⌘K)"
|
title="Search (⌘K)"
|
||||||
@ -282,6 +287,7 @@ export function Toolbar() {
|
|||||||
{/* Quick help */}
|
{/* Quick help */}
|
||||||
<div ref={helpRef} className="relative">
|
<div ref={helpRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setHelpOpen((open) => !open)}
|
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"
|
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}
|
aria-expanded={helpOpen}
|
||||||
@ -299,6 +305,7 @@ export function Toolbar() {
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<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>
|
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setHelpOpen(false)}
|
onClick={() => setHelpOpen(false)}
|
||||||
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||||
import { showToast } from "@/components/Toaster";
|
import { showToast } from "@/components/Toaster";
|
||||||
@ -250,6 +250,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
|||||||
{/* Needs restart banner */}
|
{/* Needs restart banner */}
|
||||||
{data.needsRestart && !data.currentTask && (
|
{data.needsRestart && !data.currentTask && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||||
@ -317,6 +318,197 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
|
|||||||
return count;
|
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[] {
|
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
|
||||||
if (!agentCard) return [];
|
if (!agentCard) return [];
|
||||||
|
|||||||
@ -125,6 +125,7 @@ export function OrgTokensTab() {
|
|||||||
onChange={(e) => setNameInput(e.target.value)}
|
onChange={(e) => setNameInput(e.target.value)}
|
||||||
placeholder="Label (e.g. zapier, my-ci)"
|
placeholder="Label (e.g. zapier, my-ci)"
|
||||||
maxLength={100}
|
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"
|
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
|
<button
|
||||||
|
|||||||
@ -62,6 +62,7 @@ function GearIcon() {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<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" />
|
<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 }) {
|
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 (
|
return (
|
||||||
<div>
|
<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
|
<input
|
||||||
|
id={id}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
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" : ""}`}
|
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>
|
</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 }) {
|
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 (
|
return (
|
||||||
<div>
|
<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
|
<input
|
||||||
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
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"
|
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>
|
</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 }) {
|
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("");
|
const [input, setInput] = useState("");
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
{values.map((v, i) => (
|
{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">
|
<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>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id={id}
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
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"}
|
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"
|
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>
|
</div>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function RevealToggle({
|
|||||||
|
|
||||||
function EyeIcon() {
|
function EyeIcon() {
|
||||||
return (
|
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" />
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -39,7 +39,7 @@ function EyeIcon() {
|
|||||||
|
|
||||||
function EyeOffIcon() {
|
function EyeOffIcon() {
|
||||||
return (
|
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="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" />
|
<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" />
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user