@geminilight/mindos 0.6.29 → 0.6.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/acp/config/route.ts +82 -0
  3. package/app/app/api/acp/detect/route.ts +71 -48
  4. package/app/app/api/acp/install/route.ts +51 -0
  5. package/app/app/api/acp/session/route.ts +141 -11
  6. package/app/app/api/ask/route.ts +116 -13
  7. package/app/app/api/workflows/route.ts +156 -0
  8. package/app/app/page.tsx +7 -2
  9. package/app/components/ActivityBar.tsx +12 -4
  10. package/app/components/AskModal.tsx +4 -1
  11. package/app/components/FileTree.tsx +21 -10
  12. package/app/components/HomeContent.tsx +1 -0
  13. package/app/components/Panel.tsx +1 -0
  14. package/app/components/RightAskPanel.tsx +5 -1
  15. package/app/components/SidebarLayout.tsx +6 -0
  16. package/app/components/agents/AgentDetailContent.tsx +263 -47
  17. package/app/components/agents/AgentsContentPage.tsx +11 -0
  18. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  19. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  20. package/app/components/agents/agents-content-model.ts +2 -2
  21. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  22. package/app/components/ask/AskContent.tsx +197 -239
  23. package/app/components/ask/FileChip.tsx +82 -17
  24. package/app/components/ask/MentionPopover.tsx +21 -3
  25. package/app/components/ask/MessageList.tsx +30 -9
  26. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  27. package/app/components/panels/AgentsPanel.tsx +1 -0
  28. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  29. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  30. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  31. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  32. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  33. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  34. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  35. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  36. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  37. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  38. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  39. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  40. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  41. package/app/hooks/useAcpConfig.ts +96 -0
  42. package/app/hooks/useAcpDetection.ts +69 -14
  43. package/app/hooks/useAcpRegistry.ts +46 -11
  44. package/app/hooks/useAskModal.ts +12 -5
  45. package/app/hooks/useAskPanel.ts +8 -5
  46. package/app/hooks/useAskSession.ts +19 -2
  47. package/app/hooks/useImageUpload.ts +152 -0
  48. package/app/lib/acp/acp-tools.ts +3 -1
  49. package/app/lib/acp/agent-descriptors.ts +274 -0
  50. package/app/lib/acp/bridge.ts +6 -0
  51. package/app/lib/acp/index.ts +20 -4
  52. package/app/lib/acp/registry.ts +74 -7
  53. package/app/lib/acp/session.ts +481 -28
  54. package/app/lib/acp/subprocess.ts +307 -21
  55. package/app/lib/acp/types.ts +158 -20
  56. package/app/lib/agent/model.ts +18 -3
  57. package/app/lib/agent/to-agent-messages.ts +25 -2
  58. package/app/lib/i18n/modules/knowledge.ts +4 -0
  59. package/app/lib/i18n/modules/navigation.ts +2 -0
  60. package/app/lib/i18n/modules/panels.ts +146 -2
  61. package/app/lib/pi-integration/skills.ts +21 -6
  62. package/app/lib/renderers/index.ts +2 -2
  63. package/app/lib/settings.ts +10 -0
  64. package/app/lib/types.ts +12 -1
  65. package/app/next-env.d.ts +1 -1
  66. package/app/package.json +3 -1
  67. package/package.json +1 -1
  68. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  69. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  70. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  71. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -1,31 +1,96 @@
1
1
  'use client';
2
2
 
3
- import { X, FileText, Table, Paperclip } from 'lucide-react';
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { X, FileText, Table, Paperclip, ImageIcon, Zap, Bot } from 'lucide-react';
4
6
 
5
7
  interface FileChipProps {
6
8
  path: string;
7
9
  onRemove: () => void;
8
- variant?: 'kb' | 'upload';
10
+ variant?: 'kb' | 'upload' | 'image' | 'skill' | 'agent';
11
+ /** Base64 image data for hover preview (variant='image' only) */
12
+ imageData?: string;
13
+ /** MIME type for image preview */
14
+ imageMime?: string;
9
15
  }
10
16
 
11
- export default function FileChip({ path, onRemove, variant = 'kb' }: FileChipProps) {
17
+ const VARIANT_ICON = {
18
+ kb: { icon: FileText, cls: 'text-muted-foreground' },
19
+ upload: { icon: Paperclip, cls: 'text-muted-foreground' },
20
+ image: { icon: ImageIcon, cls: 'text-muted-foreground' },
21
+ skill: { icon: Zap, cls: 'text-[var(--amber)]' },
22
+ agent: { icon: Bot, cls: 'text-muted-foreground' },
23
+ } as const;
24
+
25
+ const VARIANT_STYLE = {
26
+ kb: 'border-border bg-muted text-foreground',
27
+ upload: 'border-border bg-muted text-foreground',
28
+ image: 'border-border bg-muted text-foreground',
29
+ skill: 'border-[var(--amber)]/25 bg-[var(--amber)]/10 text-foreground',
30
+ agent: 'border-[var(--amber)]/25 bg-[var(--amber)]/10 text-foreground',
31
+ } as const;
32
+
33
+ export default function FileChip({ path, onRemove, variant = 'kb', imageData, imageMime }: FileChipProps) {
34
+ const [showPreview, setShowPreview] = useState(false);
35
+ const [mounted, setMounted] = useState(false);
36
+ const chipRef = useRef<HTMLSpanElement>(null);
37
+ const [previewPos, setPreviewPos] = useState<{ top: number; left: number } | null>(null);
38
+
39
+ useEffect(() => setMounted(true), []);
40
+
12
41
  const name = path.split('/').pop() ?? path;
13
- const isCsv = name.endsWith('.csv');
14
- const Icon = variant === 'upload' ? Paperclip : isCsv ? Table : FileText;
15
- const iconClass = variant === 'upload' ? 'text-muted-foreground' : isCsv ? 'text-success' : 'text-muted-foreground';
42
+ const isCsv = variant === 'kb' && name.endsWith('.csv');
43
+ const { icon: Icon, cls: iconClass } = isCsv
44
+ ? { icon: Table, cls: 'text-success' }
45
+ : VARIANT_ICON[variant];
46
+ const style = VARIANT_STYLE[variant];
47
+
48
+ // Position preview above the chip using getBoundingClientRect
49
+ useEffect(() => {
50
+ if (!showPreview || !chipRef.current) { setPreviewPos(null); return; }
51
+ const rect = chipRef.current.getBoundingClientRect();
52
+ setPreviewPos({
53
+ top: rect.top - 8, // 8px gap above chip
54
+ left: rect.left,
55
+ });
56
+ }, [showPreview]);
57
+
58
+ const preview = showPreview && previewPos && imageData && imageMime ? (
59
+ <div
60
+ className="fixed z-50 p-1 rounded-lg border border-border bg-card shadow-lg pointer-events-none"
61
+ style={{
62
+ left: previewPos.left,
63
+ bottom: window.innerHeight - previewPos.top,
64
+ }}
65
+ >
66
+ <img
67
+ src={`data:${imageMime};base64,${imageData}`}
68
+ alt={name}
69
+ className="max-h-48 max-w-64 rounded object-contain"
70
+ />
71
+ </div>
72
+ ) : null;
16
73
 
17
74
  return (
18
- <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border border-border bg-muted text-foreground max-w-[220px]">
19
- <Icon size={11} className={`${iconClass} shrink-0`} />
20
- <span className="truncate" title={path}>{name}</span>
21
- <button
22
- type="button"
23
- onClick={onRemove}
24
- aria-label={`Remove ${name}`}
25
- className="p-1 -mr-1 rounded hover:bg-muted hover:text-foreground transition-colors shrink-0"
75
+ <>
76
+ <span
77
+ ref={chipRef}
78
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border max-w-[220px] ${style}`}
79
+ onMouseEnter={() => imageData && setShowPreview(true)}
80
+ onMouseLeave={() => setShowPreview(false)}
26
81
  >
27
- <X size={10} />
28
- </button>
29
- </span>
82
+ <Icon size={11} className={`${iconClass} shrink-0`} />
83
+ <span className="truncate" title={path}>{name}</span>
84
+ <button
85
+ type="button"
86
+ onClick={onRemove}
87
+ aria-label={`Remove ${name}`}
88
+ className="p-0.5 -mr-1 rounded hover:text-foreground transition-colors shrink-0 text-muted-foreground"
89
+ >
90
+ <X size={10} />
91
+ </button>
92
+ </span>
93
+ {mounted && preview && createPortal(preview, document.body)}
94
+ </>
30
95
  );
31
96
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
4
  import { FileText, Table, FolderOpen } from 'lucide-react';
5
5
  import HighlightMatch from './HighlightMatch';
6
6
 
@@ -11,8 +11,26 @@ interface MentionPopoverProps {
11
11
  onSelect: (filePath: string) => void;
12
12
  }
13
13
 
14
+ /**
15
+ * Popover for @ file mentions. Renders as a flex child within the AskContent
16
+ * column (not absolutely positioned) to avoid clipping by the panel's
17
+ * overflow-hidden. Uses a dynamic max-height that caps at 50% of the
18
+ * viewport so the popover never overflows the visible area.
19
+ */
14
20
  export default function MentionPopover({ results, selectedIndex, query, onSelect }: MentionPopoverProps) {
15
21
  const listRef = useRef<HTMLDivElement>(null);
22
+ const containerRef = useRef<HTMLDivElement>(null);
23
+ const [maxListH, setMaxListH] = useState(280);
24
+
25
+ // Dynamically compute max list height based on available space
26
+ useEffect(() => {
27
+ const container = containerRef.current;
28
+ if (!container) return;
29
+ const rect = container.getBoundingClientRect();
30
+ // Reserve ~80px for header + footer chrome, and cap at 50vh
31
+ const available = Math.min(rect.top - 8, window.innerHeight * 0.5) - 80;
32
+ setMaxListH(Math.max(120, Math.min(320, available)));
33
+ }, [results.length]);
16
34
 
17
35
  useEffect(() => {
18
36
  const container = listRef.current;
@@ -24,13 +42,13 @@ export default function MentionPopover({ results, selectedIndex, query, onSelect
24
42
  if (results.length === 0) return null;
25
43
 
26
44
  return (
27
- <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
45
+ <div ref={containerRef} className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
28
46
  <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
29
47
  <FolderOpen size={11} className="text-muted-foreground/50" />
30
48
  <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Files</span>
31
49
  <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
32
50
  </div>
33
- <div ref={listRef} className="max-h-[360px] overflow-y-auto">
51
+ <div ref={listRef} className="overflow-y-auto" style={{ maxHeight: maxListH }}>
34
52
  {results.map((f, idx) => {
35
53
  const name = f.split('/').pop() ?? f;
36
54
  const dir = f.split('/').slice(0, -1).join('/');
@@ -4,25 +4,46 @@ import { useRef, useEffect } from 'react';
4
4
  import { Sparkles, Loader2, AlertCircle, Wrench, WifiOff, Zap } from 'lucide-react';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import remarkGfm from 'remark-gfm';
7
- import type { Message } from '@/lib/types';
7
+ import type { Message, ImagePart } from '@/lib/types';
8
8
  import { stripThinkingTags } from '@/hooks/useAiOrganize';
9
9
  import ToolCallBlock from './ToolCallBlock';
10
10
  import ThinkingBlock from './ThinkingBlock';
11
11
 
12
12
  const SKILL_PREFIX_RE = /^Use the skill ([^:]+):\s*/;
13
13
 
14
- function UserMessageContent({ content, skillName }: { content: string; skillName?: string }) {
14
+ function UserMessageContent({ content, skillName, images }: { content: string; skillName?: string; images?: ImagePart[] }) {
15
15
  const resolved = skillName ?? content.match(SKILL_PREFIX_RE)?.[1];
16
- if (!resolved) return <>{content}</>;
17
16
  const prefixMatch = content.match(SKILL_PREFIX_RE);
18
17
  const rest = prefixMatch ? content.slice(prefixMatch[0].length) : content;
19
18
  return (
20
19
  <>
21
- <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] font-medium bg-[var(--amber)]/15 text-[var(--amber)] mr-1 align-middle">
22
- <Zap size={10} className="shrink-0" />
23
- {resolved}
24
- </span>
25
- {rest}
20
+ {/* Images */}
21
+ {images && images.length > 0 && (
22
+ <div className={`flex flex-wrap gap-1.5${content ? ' mb-2' : ''}`}>
23
+ {images.map((img, idx) => (
24
+ img.data ? (
25
+ <img
26
+ key={idx}
27
+ src={`data:${img.mimeType};base64,${img.data}`}
28
+ alt={`Image ${idx + 1}`}
29
+ className="max-h-48 max-w-full rounded-md object-contain"
30
+ />
31
+ ) : (
32
+ <div key={idx} className="h-12 px-3 rounded-md bg-muted flex items-center gap-1.5 text-xs text-muted-foreground">
33
+ <span>[Image {idx + 1}]</span>
34
+ </div>
35
+ )
36
+ ))}
37
+ </div>
38
+ )}
39
+ {/* Skill capsule + text */}
40
+ {resolved && (
41
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] font-medium bg-[var(--amber)]/15 text-[var(--amber)] mr-1 align-middle">
42
+ <Zap size={10} className="shrink-0" />
43
+ {resolved}
44
+ </span>
45
+ )}
46
+ {resolved ? rest : content}
26
47
  </>
27
48
  );
28
49
  }
@@ -168,7 +189,7 @@ export default function MessageList({
168
189
  <div
169
190
  className="max-w-[85%] px-3 py-2 rounded-xl rounded-br-sm text-sm leading-relaxed whitespace-pre-wrap bg-[var(--amber)] text-[var(--amber-foreground)]"
170
191
  >
171
- <UserMessageContent content={m.content} skillName={m.skillName} />
192
+ <UserMessageContent content={m.content} skillName={m.skillName} images={m.images} />
172
193
  </div>
173
194
  ) : m.content.startsWith('__error__') ? (
174
195
  <div className="max-w-[85%] px-3 py-2.5 rounded-xl rounded-bl-sm border border-error/20 bg-error/8 text-sm">
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
4
  import { Zap } from 'lucide-react';
5
5
  import type { SlashItem } from '@/hooks/useSlashCommand';
6
6
  import HighlightMatch from './HighlightMatch';
@@ -12,8 +12,26 @@ interface SlashCommandPopoverProps {
12
12
  onSelect: (item: SlashItem) => void;
13
13
  }
14
14
 
15
+ /**
16
+ * Popover for slash commands. Renders as a flex child within the AskContent
17
+ * column (not absolutely positioned) to avoid clipping by the panel's
18
+ * overflow-hidden. Uses a dynamic max-height that caps at 50% of the
19
+ * viewport so the popover never overflows the visible area.
20
+ */
15
21
  export default function SlashCommandPopover({ results, selectedIndex, query, onSelect }: SlashCommandPopoverProps) {
16
22
  const listRef = useRef<HTMLDivElement>(null);
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const [maxListH, setMaxListH] = useState(280);
25
+
26
+ // Dynamically compute max list height based on available space
27
+ useEffect(() => {
28
+ const container = containerRef.current;
29
+ if (!container) return;
30
+ const rect = container.getBoundingClientRect();
31
+ // Reserve ~80px for header + footer chrome, and cap at 50vh
32
+ const available = Math.min(rect.top - 8, window.innerHeight * 0.5) - 80;
33
+ setMaxListH(Math.max(120, Math.min(320, available)));
34
+ }, [results.length]);
17
35
 
18
36
  useEffect(() => {
19
37
  const container = listRef.current;
@@ -25,13 +43,13 @@ export default function SlashCommandPopover({ results, selectedIndex, query, onS
25
43
  if (results.length === 0) return null;
26
44
 
27
45
  return (
28
- <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
46
+ <div ref={containerRef} className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
29
47
  <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
30
48
  <Zap size={11} className="text-[var(--amber)]/50" />
31
49
  <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Skills</span>
32
50
  <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
33
51
  </div>
34
- <div ref={listRef} className="max-h-[360px] overflow-y-auto">
52
+ <div ref={listRef} className="overflow-y-auto" style={{ maxHeight: maxListH }}>
35
53
  {results.map((item, idx) => (
36
54
  <button
37
55
  key={item.name}
@@ -71,6 +71,7 @@ export default function AgentsPanel({
71
71
  navMcp: p.navMcp,
72
72
  navSkills: p.navSkills,
73
73
  navNetwork: p.navNetwork,
74
+ navSessions: p.navSessions,
74
75
  };
75
76
 
76
77
  const hub = (
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { usePathname, useSearchParams } from 'next/navigation';
4
- import { Globe, LayoutDashboard, Server, Zap } from 'lucide-react';
4
+ import { Globe, History, LayoutDashboard, Server, Zap } from 'lucide-react';
5
5
  import { PanelNavRow } from './PanelNavRow';
6
6
 
7
7
  type HubCopy = {
@@ -9,6 +9,7 @@ type HubCopy = {
9
9
  navMcp: string;
10
10
  navSkills: string;
11
11
  navNetwork: string;
12
+ navSessions: string;
12
13
  };
13
14
 
14
15
  export function AgentsPanelHubNav({
@@ -26,7 +27,7 @@ export function AgentsPanelHubNav({
26
27
  return (
27
28
  <div className="py-2">
28
29
  <PanelNavRow
29
- icon={<LayoutDashboard size={14} className="text-[var(--amber)]" />}
30
+ icon={<LayoutDashboard size={14} className={inAgentsRoute && (tab === null || tab === 'overview') ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
30
31
  title={copy.navOverview}
31
32
  badge={<span className="text-2xs tabular-nums text-muted-foreground/60 px-1.5 py-0.5 rounded bg-muted/40 font-medium">{connectedCount}</span>}
32
33
  href="/agents"
@@ -50,6 +51,12 @@ export function AgentsPanelHubNav({
50
51
  href="/agents?tab=a2a"
51
52
  active={inAgentsRoute && tab === 'a2a'}
52
53
  />
54
+ <PanelNavRow
55
+ icon={<History size={14} className={inAgentsRoute && tab === 'sessions' ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
56
+ title={copy.navSessions}
57
+ href="/agents?tab=sessions"
58
+ active={inAgentsRoute && tab === 'sessions'}
59
+ />
53
60
  </div>
54
61
  );
55
62
  }
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import Link from 'next/link';
5
+ import { Plus, Zap, AlertTriangle, Loader2 } from 'lucide-react';
6
+ import PanelHeader from './PanelHeader';
7
+ import { useLocale } from '@/lib/LocaleContext';
8
+ import { encodePath, relativeTime } from '@/lib/utils';
9
+
10
+ interface WorkflowItem {
11
+ path: string;
12
+ fileName: string;
13
+ title: string;
14
+ description?: string;
15
+ stepCount: number;
16
+ mtime: number;
17
+ error?: string;
18
+ }
19
+
20
+ interface WorkflowsPanelProps {
21
+ active: boolean;
22
+ maximized?: boolean;
23
+ onMaximize?: () => void;
24
+ }
25
+
26
+ export default function WorkflowsPanel({ active, maximized, onMaximize }: WorkflowsPanelProps) {
27
+ const { t } = useLocale();
28
+ const wt = t.panels.workflows as {
29
+ title: string;
30
+ empty: string;
31
+ emptyDesc: string;
32
+ newWorkflow: string;
33
+ nSteps: (n: number) => string;
34
+ parseError: string;
35
+ name: string;
36
+ namePlaceholder: string;
37
+ template: string;
38
+ templateBlank: string;
39
+ create: string;
40
+ cancel: string;
41
+ creating: string;
42
+ exists: string;
43
+ };
44
+
45
+ const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
46
+ const [loading, setLoading] = useState(true);
47
+ const [showCreate, setShowCreate] = useState(false);
48
+ const [newName, setNewName] = useState('');
49
+ const [creating, setCreating] = useState(false);
50
+ const [createError, setCreateError] = useState('');
51
+
52
+ const fetchWorkflows = useCallback(async () => {
53
+ try {
54
+ const res = await fetch('/api/workflows');
55
+ if (!res.ok) return;
56
+ const data = await res.json();
57
+ setWorkflows(data.workflows ?? []);
58
+ } catch {
59
+ // silent
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ }, []);
64
+
65
+ useEffect(() => {
66
+ if (active) fetchWorkflows();
67
+ }, [active, fetchWorkflows]);
68
+
69
+ const handleCreate = async () => {
70
+ const name = newName.trim();
71
+ if (!name || creating) return;
72
+ setCreating(true);
73
+ setCreateError('');
74
+
75
+ try {
76
+ const res = await fetch('/api/workflows', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ name }),
80
+ });
81
+ const data = await res.json();
82
+ if (!res.ok) {
83
+ setCreateError(res.status === 409 ? wt.exists : (data.error || 'Error'));
84
+ return;
85
+ }
86
+ // Refresh list and navigate to new file
87
+ setShowCreate(false);
88
+ setNewName('');
89
+ await fetchWorkflows();
90
+ window.location.href = `/view/${encodePath(data.path)}`;
91
+ } catch {
92
+ setCreateError('Network error');
93
+ } finally {
94
+ setCreating(false);
95
+ }
96
+ };
97
+
98
+ return (
99
+ <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
100
+ <PanelHeader title={wt.title} maximized={maximized} onMaximize={onMaximize}>
101
+ <button
102
+ onClick={() => setShowCreate(v => !v)}
103
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors focus-visible:ring-1 focus-visible:ring-ring"
104
+ aria-label={wt.newWorkflow}
105
+ title={wt.newWorkflow}
106
+ >
107
+ <Plus size={13} />
108
+ </button>
109
+ </PanelHeader>
110
+
111
+ <div className="flex-1 overflow-y-auto min-h-0">
112
+ {/* Create form */}
113
+ {showCreate && (
114
+ <div className="px-3 py-3 border-b border-border">
115
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">{wt.name}</label>
116
+ <input
117
+ type="text"
118
+ value={newName}
119
+ onChange={e => { setNewName(e.target.value); setCreateError(''); }}
120
+ onKeyDown={e => e.key === 'Enter' && handleCreate()}
121
+ placeholder={wt.namePlaceholder}
122
+ autoFocus
123
+ className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
124
+ />
125
+ {createError && (
126
+ <p className="text-xs text-[var(--error)] mt-1">{createError}</p>
127
+ )}
128
+ <div className="flex gap-2 mt-2.5">
129
+ <button
130
+ onClick={() => { setShowCreate(false); setNewName(''); setCreateError(''); }}
131
+ className="flex-1 px-3 py-1.5 text-xs rounded-md border border-border text-muted-foreground hover:bg-muted transition-colors"
132
+ >
133
+ {wt.cancel}
134
+ </button>
135
+ <button
136
+ onClick={handleCreate}
137
+ disabled={!newName.trim() || creating}
138
+ className="flex-1 px-3 py-1.5 text-xs rounded-md font-medium transition-colors disabled:opacity-50 bg-[var(--amber)] text-[var(--amber-foreground)]"
139
+ >
140
+ {creating ? wt.creating : wt.create}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ )}
145
+
146
+ {/* Loading */}
147
+ {loading && (
148
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
149
+ <Loader2 size={16} className="animate-spin" />
150
+ </div>
151
+ )}
152
+
153
+ {/* Empty state */}
154
+ {!loading && workflows.length === 0 && !showCreate && (
155
+ <div className="flex flex-col items-center justify-center py-10 px-4 text-center">
156
+ <Zap size={24} className="text-muted-foreground/40 mb-3" />
157
+ <p className="text-sm font-medium text-muted-foreground mb-1">{wt.empty}</p>
158
+ <p className="text-xs text-muted-foreground/70 mb-4 max-w-[200px]">{wt.emptyDesc}</p>
159
+ <button
160
+ onClick={() => setShowCreate(true)}
161
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors"
162
+ >
163
+ <Plus size={12} />
164
+ {wt.newWorkflow}
165
+ </button>
166
+ </div>
167
+ )}
168
+
169
+ {/* Workflow list */}
170
+ {!loading && workflows.length > 0 && (
171
+ <div className="flex flex-col gap-0.5 py-1.5">
172
+ {workflows.map(w => (
173
+ <Link
174
+ key={w.path}
175
+ href={`/view/${encodePath(w.path)}`}
176
+ className={`flex items-start gap-2.5 px-3 py-2 mx-1 rounded-lg transition-colors hover:bg-muted ${
177
+ w.error ? 'opacity-70' : ''
178
+ }`}
179
+ >
180
+ <Zap size={14} className="shrink-0 mt-0.5 text-[var(--amber)]" />
181
+ <div className="min-w-0 flex-1">
182
+ <span className="text-sm font-medium text-foreground block truncate">{w.title}</span>
183
+ <div className="flex items-center gap-2 mt-0.5">
184
+ {w.error ? (
185
+ <span className="inline-flex items-center gap-1 text-2xs text-[var(--error)]">
186
+ <AlertTriangle size={10} />
187
+ {wt.parseError}
188
+ </span>
189
+ ) : (
190
+ <span className="text-2xs text-muted-foreground">
191
+ {wt.nSteps(w.stepCount)}
192
+ </span>
193
+ )}
194
+ <span className="text-2xs text-muted-foreground/60" suppressHydrationWarning>
195
+ {relativeTime(w.mtime, t.home?.relativeTime)}
196
+ </span>
197
+ </div>
198
+ </div>
199
+ </Link>
200
+ ))}
201
+ </div>
202
+ )}
203
+ </div>
204
+ </div>
205
+ );
206
+ }