@geminilight/mindos 0.6.28 → 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.
- package/README.md +10 -4
- package/app/app/api/a2a/agents/route.ts +9 -0
- package/app/app/api/a2a/delegations/route.ts +9 -0
- package/app/app/api/a2a/discover/route.ts +2 -0
- package/app/app/api/a2a/route.ts +6 -6
- package/app/app/api/acp/config/route.ts +82 -0
- package/app/app/api/acp/detect/route.ts +114 -0
- package/app/app/api/acp/install/route.ts +51 -0
- package/app/app/api/acp/registry/route.ts +31 -0
- package/app/app/api/acp/session/route.ts +185 -0
- package/app/app/api/ask/route.ts +116 -13
- package/app/app/api/workflows/route.ts +156 -0
- package/app/app/layout.tsx +2 -0
- package/app/app/page.tsx +7 -2
- package/app/components/ActivityBar.tsx +12 -4
- package/app/components/AskModal.tsx +4 -1
- package/app/components/DirView.tsx +64 -2
- package/app/components/FileTree.tsx +40 -10
- package/app/components/GuideCard.tsx +7 -17
- package/app/components/HomeContent.tsx +1 -0
- package/app/components/MarkdownView.tsx +2 -0
- package/app/components/Panel.tsx +1 -0
- package/app/components/RightAskPanel.tsx +5 -1
- package/app/components/SearchModal.tsx +234 -80
- package/app/components/SidebarLayout.tsx +6 -0
- package/app/components/agents/AgentDetailContent.tsx +266 -52
- package/app/components/agents/AgentsContentPage.tsx +32 -6
- package/app/components/agents/AgentsPanelA2aTab.tsx +684 -0
- package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
- package/app/components/agents/SkillDetailPopover.tsx +4 -9
- package/app/components/agents/agents-content-model.ts +2 -2
- package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
- package/app/components/ask/AskContent.tsx +197 -239
- package/app/components/ask/FileChip.tsx +82 -17
- package/app/components/ask/MentionPopover.tsx +21 -3
- package/app/components/ask/MessageList.tsx +30 -9
- package/app/components/ask/SlashCommandPopover.tsx +21 -3
- package/app/components/help/HelpContent.tsx +9 -9
- package/app/components/panels/AgentsPanel.tsx +2 -0
- package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -8
- package/app/components/panels/AgentsPanelHubNav.tsx +16 -2
- package/app/components/panels/EchoPanel.tsx +5 -1
- package/app/components/panels/EchoSidebarStats.tsx +136 -0
- package/app/components/panels/WorkflowsPanel.tsx +206 -0
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
- package/app/components/renderers/workflow-yaml/execution.ts +177 -0
- package/app/components/renderers/workflow-yaml/index.ts +6 -0
- package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
- package/app/components/renderers/workflow-yaml/parser.ts +172 -0
- package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
- package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
- package/app/components/renderers/workflow-yaml/types.ts +46 -0
- package/app/components/settings/KnowledgeTab.tsx +3 -6
- package/app/components/settings/McpSkillsSection.tsx +4 -5
- package/app/components/settings/McpTab.tsx +6 -8
- package/app/components/setup/StepSecurity.tsx +4 -5
- package/app/components/setup/index.tsx +5 -11
- package/app/components/ui/Toaster.tsx +39 -0
- package/app/hooks/useA2aRegistry.ts +6 -1
- package/app/hooks/useAcpConfig.ts +96 -0
- package/app/hooks/useAcpDetection.ts +120 -0
- package/app/hooks/useAcpRegistry.ts +86 -0
- package/app/hooks/useAskModal.ts +12 -5
- package/app/hooks/useAskPanel.ts +8 -5
- package/app/hooks/useAskSession.ts +19 -2
- package/app/hooks/useDelegationHistory.ts +49 -0
- package/app/hooks/useImageUpload.ts +152 -0
- package/app/lib/a2a/client.ts +49 -5
- package/app/lib/a2a/orchestrator.ts +0 -1
- package/app/lib/a2a/task-handler.ts +4 -4
- package/app/lib/a2a/types.ts +15 -0
- package/app/lib/acp/acp-tools.ts +95 -0
- package/app/lib/acp/agent-descriptors.ts +274 -0
- package/app/lib/acp/bridge.ts +144 -0
- package/app/lib/acp/index.ts +40 -0
- package/app/lib/acp/registry.ts +202 -0
- package/app/lib/acp/session.ts +717 -0
- package/app/lib/acp/subprocess.ts +495 -0
- package/app/lib/acp/types.ts +274 -0
- package/app/lib/agent/model.ts +18 -3
- package/app/lib/agent/to-agent-messages.ts +25 -2
- package/app/lib/agent/tools.ts +2 -1
- package/app/lib/i18n/_core.ts +22 -0
- package/app/lib/i18n/index.ts +35 -0
- package/app/lib/i18n/modules/ai-chat.ts +215 -0
- package/app/lib/i18n/modules/common.ts +71 -0
- package/app/lib/i18n/modules/features.ts +153 -0
- package/app/lib/i18n/modules/knowledge.ts +429 -0
- package/app/lib/i18n/modules/navigation.ts +153 -0
- package/app/lib/i18n/modules/onboarding.ts +523 -0
- package/app/lib/i18n/modules/panels.ts +1196 -0
- package/app/lib/i18n/modules/settings.ts +585 -0
- package/app/lib/i18n-en.ts +2 -1518
- package/app/lib/i18n-zh.ts +2 -1542
- package/app/lib/i18n.ts +3 -6
- package/app/lib/pi-integration/skills.ts +21 -6
- package/app/lib/renderers/index.ts +2 -2
- package/app/lib/settings.ts +10 -0
- package/app/lib/toast.ts +79 -0
- package/app/lib/types.ts +12 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +3 -1
- package/bin/cli.js +25 -25
- package/bin/commands/file.js +29 -2
- package/bin/commands/space.js +249 -91
- package/package.json +1 -1
- package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
- package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
- package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
- package/app/components/renderers/workflow/manifest.ts +0 -14
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import YAML from 'js-yaml';
|
|
2
|
+
import type { WorkflowYaml, ParseResult, ValidationResult, WorkflowStep } from './types';
|
|
3
|
+
|
|
4
|
+
export function parseWorkflowYaml(content: string): ParseResult {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = YAML.load(content, { schema: YAML.JSON_SCHEMA }) as unknown;
|
|
7
|
+
|
|
8
|
+
if (!parsed) {
|
|
9
|
+
return {
|
|
10
|
+
workflow: null,
|
|
11
|
+
errors: ['File is empty'],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof parsed !== 'object') {
|
|
16
|
+
return {
|
|
17
|
+
workflow: null,
|
|
18
|
+
errors: ['Content is not valid YAML'],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const validation = validateWorkflowSchema(parsed);
|
|
23
|
+
if (!validation.valid) {
|
|
24
|
+
return {
|
|
25
|
+
workflow: null,
|
|
26
|
+
errors: validation.errors,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
workflow: parsed as WorkflowYaml,
|
|
32
|
+
errors: [],
|
|
33
|
+
};
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return {
|
|
36
|
+
workflow: null,
|
|
37
|
+
errors: [err instanceof Error ? err.message : String(err)],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateWorkflowSchema(obj: unknown): ValidationResult {
|
|
43
|
+
const errors: string[] = [];
|
|
44
|
+
|
|
45
|
+
if (!obj || typeof obj !== 'object') {
|
|
46
|
+
return { valid: false, errors: ['Must be a valid YAML object'] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const w = obj as Record<string, any>;
|
|
50
|
+
|
|
51
|
+
// Validate title
|
|
52
|
+
if (!w.title || typeof w.title !== 'string' || !w.title.trim()) {
|
|
53
|
+
errors.push("Missing required field 'title' (string)");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Validate description (optional)
|
|
57
|
+
if (w.description !== undefined && typeof w.description !== 'string') {
|
|
58
|
+
errors.push("'description' must be a string");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate skills (optional)
|
|
62
|
+
if (w.skills !== undefined) {
|
|
63
|
+
if (!Array.isArray(w.skills)) {
|
|
64
|
+
errors.push("'skills' must be an array of strings");
|
|
65
|
+
} else if (!w.skills.every((s: any) => typeof s === 'string')) {
|
|
66
|
+
errors.push("'skills' must contain only strings");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate tools (optional)
|
|
71
|
+
if (w.tools !== undefined) {
|
|
72
|
+
if (!Array.isArray(w.tools)) {
|
|
73
|
+
errors.push("'tools' must be an array of strings");
|
|
74
|
+
} else if (!w.tools.every((t: any) => typeof t === 'string')) {
|
|
75
|
+
errors.push("'tools' must contain only strings");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate steps
|
|
80
|
+
if (!w.steps) {
|
|
81
|
+
errors.push("Missing required field 'steps' (array)");
|
|
82
|
+
} else if (!Array.isArray(w.steps)) {
|
|
83
|
+
errors.push("'steps' must be an array");
|
|
84
|
+
} else if (w.steps.length === 0) {
|
|
85
|
+
errors.push("'steps' must have at least 1 step");
|
|
86
|
+
} else {
|
|
87
|
+
const seenIds = new Set<string>();
|
|
88
|
+
w.steps.forEach((step: any, idx: number) => {
|
|
89
|
+
const stepErrors = validateStep(step, idx);
|
|
90
|
+
errors.push(...stepErrors);
|
|
91
|
+
// Check for duplicate IDs
|
|
92
|
+
const id = step?.id;
|
|
93
|
+
if (typeof id === 'string' && id.trim()) {
|
|
94
|
+
if (seenIds.has(id)) {
|
|
95
|
+
errors.push(`steps[${idx}].id: duplicate id '${id}' (each step must have a unique id)`);
|
|
96
|
+
}
|
|
97
|
+
seenIds.add(id);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
valid: errors.length === 0,
|
|
104
|
+
errors,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateStep(step: unknown, index: number): string[] {
|
|
109
|
+
const errors: string[] = [];
|
|
110
|
+
const prefix = `steps[${index}]`;
|
|
111
|
+
|
|
112
|
+
if (!step || typeof step !== 'object') {
|
|
113
|
+
errors.push(`${prefix}: must be a valid object`);
|
|
114
|
+
return errors;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const s = step as Record<string, any>;
|
|
118
|
+
|
|
119
|
+
// Validate id
|
|
120
|
+
if (!s.id || typeof s.id !== 'string' || !s.id.trim()) {
|
|
121
|
+
errors.push(`${prefix}: missing required field 'id' (non-empty string)`);
|
|
122
|
+
} else if (!/^[a-z0-9_-]+$/.test(s.id)) {
|
|
123
|
+
errors.push(`${prefix}.id: '${s.id}' is invalid — use only lowercase letters, numbers, hyphens, underscores (e.g., 'run-tests')`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate name
|
|
127
|
+
if (!s.name || typeof s.name !== 'string' || !s.name.trim()) {
|
|
128
|
+
errors.push(`${prefix}: missing required field 'name' (non-empty string)`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate description (optional)
|
|
132
|
+
if (s.description !== undefined && typeof s.description !== 'string') {
|
|
133
|
+
errors.push(`${prefix}.description: must be a string`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate agent (optional)
|
|
137
|
+
if (s.agent !== undefined && (typeof s.agent !== 'string' || !s.agent.trim())) {
|
|
138
|
+
errors.push(`${prefix}.agent: must be a non-empty string`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate skill (optional)
|
|
142
|
+
if (s.skill !== undefined && (typeof s.skill !== 'string' || !s.skill.trim())) {
|
|
143
|
+
errors.push(`${prefix}.skill: must be a non-empty string`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate tools (optional)
|
|
147
|
+
if (s.tools !== undefined) {
|
|
148
|
+
if (!Array.isArray(s.tools)) {
|
|
149
|
+
errors.push(`${prefix}.tools: must be an array of strings`);
|
|
150
|
+
} else if (!s.tools.every((t: any) => typeof t === 'string')) {
|
|
151
|
+
errors.push(`${prefix}.tools: must contain only strings`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate prompt (required)
|
|
156
|
+
if (!s.prompt || typeof s.prompt !== 'string' || !s.prompt.trim()) {
|
|
157
|
+
errors.push(`${prefix}: missing required field 'prompt' (non-empty string)`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate timeout (optional)
|
|
161
|
+
if (s.timeout !== undefined) {
|
|
162
|
+
if (typeof s.timeout !== 'number' || s.timeout <= 0) {
|
|
163
|
+
errors.push(`${prefix}.timeout: must be a positive number (seconds)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getStepDescription(step: WorkflowStep): string {
|
|
171
|
+
return step.description || step.prompt.split('\n')[0].slice(0, 100);
|
|
172
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { ChevronDown, X, Search, FileText, FolderOpen, Folder, ChevronRight } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
// ─── Dropdown Shell ───────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function Dropdown({ trigger, children, open, onClose }: {
|
|
9
|
+
trigger: React.ReactNode; children: React.ReactNode; open: boolean; onClose: () => void;
|
|
10
|
+
}) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!open) return;
|
|
14
|
+
const handler = (e: MouseEvent) => {
|
|
15
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
16
|
+
};
|
|
17
|
+
document.addEventListener('mousedown', handler);
|
|
18
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
19
|
+
}, [open, onClose]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div ref={ref} className="relative">
|
|
23
|
+
{trigger}
|
|
24
|
+
{open && (
|
|
25
|
+
<div className="absolute top-full left-0 mt-1 w-full min-w-[220px] max-h-[260px] overflow-y-auto bg-card border border-border rounded-lg shadow-lg z-50">
|
|
26
|
+
{children}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Agent Selector ───────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const KNOWN_AGENTS = ['cursor', 'claude-code', 'mindos', 'gemini'];
|
|
36
|
+
|
|
37
|
+
export function AgentSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [custom, setCustom] = useState('');
|
|
40
|
+
|
|
41
|
+
const select = (v: string | undefined) => { onChange(v); setOpen(false); };
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Dropdown
|
|
45
|
+
open={open}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
trigger={
|
|
48
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
49
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
50
|
+
<span className={value ? 'text-foreground' : 'text-muted-foreground'}>
|
|
51
|
+
{value || 'Select agent'}
|
|
52
|
+
</span>
|
|
53
|
+
<ChevronDown size={12} className="text-muted-foreground" />
|
|
54
|
+
</button>
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
<button onClick={() => select(undefined)}
|
|
58
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
|
|
59
|
+
(none)
|
|
60
|
+
</button>
|
|
61
|
+
{KNOWN_AGENTS.map(a => (
|
|
62
|
+
<button key={a} onClick={() => select(a)}
|
|
63
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === a ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
|
|
64
|
+
{a}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
<div className="border-t border-border px-2.5 py-1.5">
|
|
68
|
+
<input type="text" value={custom} onChange={e => setCustom(e.target.value)}
|
|
69
|
+
onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
|
|
70
|
+
placeholder="Custom agent..."
|
|
71
|
+
className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</Dropdown>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Model Selector ───────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const KNOWN_MODELS = [
|
|
81
|
+
{ group: 'Anthropic', models: ['claude-sonnet-4-6', 'claude-opus-4', 'claude-3.5-sonnet', 'claude-3.5-haiku'] },
|
|
82
|
+
{ group: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'] },
|
|
83
|
+
{ group: 'Google', models: ['gemini-2.5-pro', 'gemini-2.5-flash'] },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export function ModelSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
const [custom, setCustom] = useState('');
|
|
89
|
+
|
|
90
|
+
const select = (v: string | undefined) => { onChange(v); setOpen(false); };
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Dropdown
|
|
94
|
+
open={open}
|
|
95
|
+
onClose={() => setOpen(false)}
|
|
96
|
+
trigger={
|
|
97
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
98
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
99
|
+
<span className={value ? 'text-foreground truncate' : 'text-muted-foreground'}>
|
|
100
|
+
{value || 'Default model'}
|
|
101
|
+
</span>
|
|
102
|
+
{value ? (
|
|
103
|
+
<X size={12} className="text-muted-foreground shrink-0 hover:text-foreground cursor-pointer" onClick={e => { e.stopPropagation(); select(undefined); }} />
|
|
104
|
+
) : (
|
|
105
|
+
<ChevronDown size={12} className="text-muted-foreground shrink-0" />
|
|
106
|
+
)}
|
|
107
|
+
</button>
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<button onClick={() => select(undefined)}
|
|
111
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
|
|
112
|
+
(default)
|
|
113
|
+
</button>
|
|
114
|
+
{KNOWN_MODELS.map(g => (
|
|
115
|
+
<div key={g.group}>
|
|
116
|
+
<div className="px-3 py-1 text-2xs font-semibold text-muted-foreground/60 uppercase tracking-wide bg-muted/30">{g.group}</div>
|
|
117
|
+
{g.models.map(m => (
|
|
118
|
+
<button key={m} onClick={() => select(m)}
|
|
119
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === m ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
|
|
120
|
+
{m}
|
|
121
|
+
</button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
<div className="border-t border-border px-2.5 py-1.5">
|
|
126
|
+
<input type="text" value={custom} onChange={e => setCustom(e.target.value)}
|
|
127
|
+
onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
|
|
128
|
+
placeholder="Custom model ID..."
|
|
129
|
+
className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</Dropdown>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Skills Multi-Select ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
interface SkillInfo { name: string; description?: string }
|
|
139
|
+
|
|
140
|
+
/** Cache skill list globally so all SkillsSelector instances share it */
|
|
141
|
+
let _skillsCache: SkillInfo[] | null = null;
|
|
142
|
+
let _skillsFetching = false;
|
|
143
|
+
const _skillsListeners: Array<(skills: SkillInfo[]) => void> = [];
|
|
144
|
+
|
|
145
|
+
function fetchSkillsOnce(cb: (skills: SkillInfo[]) => void) {
|
|
146
|
+
if (_skillsCache) { cb(_skillsCache); return; }
|
|
147
|
+
_skillsListeners.push(cb);
|
|
148
|
+
if (_skillsFetching) return;
|
|
149
|
+
_skillsFetching = true;
|
|
150
|
+
fetch('/api/skills').then(r => r.json()).then(data => {
|
|
151
|
+
_skillsCache = (data.skills ?? []).map((s: { name: string; description?: string }) => ({
|
|
152
|
+
name: s.name, description: s.description,
|
|
153
|
+
}));
|
|
154
|
+
_skillsListeners.forEach(fn => fn(_skillsCache!));
|
|
155
|
+
_skillsListeners.length = 0;
|
|
156
|
+
}).catch(() => {
|
|
157
|
+
_skillsCache = [];
|
|
158
|
+
_skillsListeners.forEach(fn => fn([]));
|
|
159
|
+
_skillsListeners.length = 0;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function SkillsSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
|
164
|
+
const [open, setOpen] = useState(false);
|
|
165
|
+
const [skills, setSkills] = useState<SkillInfo[]>(_skillsCache ?? []);
|
|
166
|
+
const [query, setQuery] = useState('');
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
fetchSkillsOnce(setSkills);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const filtered = query
|
|
173
|
+
? skills.filter(s => s.name.toLowerCase().includes(query.toLowerCase()))
|
|
174
|
+
: skills;
|
|
175
|
+
|
|
176
|
+
const toggle = (name: string) => {
|
|
177
|
+
if (value.includes(name)) {
|
|
178
|
+
onChange(value.filter(v => v !== name));
|
|
179
|
+
} else {
|
|
180
|
+
onChange([...value, name]);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const remove = (name: string) => onChange(value.filter(v => v !== name));
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div>
|
|
188
|
+
{/* Selected chips */}
|
|
189
|
+
{value.length > 0 && (
|
|
190
|
+
<div className="flex flex-wrap gap-1 mb-1.5">
|
|
191
|
+
{value.map(s => (
|
|
192
|
+
<span key={s} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md text-2xs bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20">
|
|
193
|
+
{s}
|
|
194
|
+
<button onClick={() => remove(s)} className="p-0.5 rounded hover:bg-[var(--amber)]/20 transition-colors">
|
|
195
|
+
<X size={10} />
|
|
196
|
+
</button>
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Dropdown trigger */}
|
|
203
|
+
<Dropdown
|
|
204
|
+
open={open}
|
|
205
|
+
onClose={() => { setOpen(false); setQuery(''); }}
|
|
206
|
+
trigger={
|
|
207
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
208
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
209
|
+
<span className="text-muted-foreground">
|
|
210
|
+
{value.length === 0 ? 'Add skills...' : `${value.length} selected`}
|
|
211
|
+
</span>
|
|
212
|
+
<ChevronDown size={12} className="text-muted-foreground" />
|
|
213
|
+
</button>
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{/* Search */}
|
|
217
|
+
<div className="sticky top-0 bg-card border-b border-border px-2.5 py-1.5">
|
|
218
|
+
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-border bg-background">
|
|
219
|
+
<Search size={11} className="text-muted-foreground shrink-0" />
|
|
220
|
+
<input type="text" value={query} onChange={e => setQuery(e.target.value)} autoFocus
|
|
221
|
+
placeholder="Search skills..."
|
|
222
|
+
className="flex-1 text-xs bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Skill list with checkboxes */}
|
|
228
|
+
{filtered.slice(0, 60).map(s => {
|
|
229
|
+
const checked = value.includes(s.name);
|
|
230
|
+
return (
|
|
231
|
+
<button key={s.name} onClick={() => toggle(s.name)}
|
|
232
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2 ${checked ? 'bg-[var(--amber)]/5' : ''}`}>
|
|
233
|
+
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
|
234
|
+
checked ? 'bg-[var(--amber)] border-[var(--amber)] text-white' : 'border-border'
|
|
235
|
+
}`}>
|
|
236
|
+
{checked && <span className="text-[9px]">✓</span>}
|
|
237
|
+
</span>
|
|
238
|
+
<div className="min-w-0 flex-1">
|
|
239
|
+
<span className="block truncate">{s.name}</span>
|
|
240
|
+
{s.description && <span className="block text-2xs text-muted-foreground truncate mt-0.5">{s.description}</span>}
|
|
241
|
+
</div>
|
|
242
|
+
</button>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
{skills.length === 0 && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
|
|
246
|
+
{skills.length > 0 && filtered.length === 0 && (
|
|
247
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No skills found</div>
|
|
248
|
+
)}
|
|
249
|
+
</Dropdown>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Context Selector (file drop zone) ───────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function ContextSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
|
257
|
+
const [dragOver, setDragOver] = useState(false);
|
|
258
|
+
const [manualInput, setManualInput] = useState('');
|
|
259
|
+
|
|
260
|
+
const addPath = useCallback((path: string) => {
|
|
261
|
+
const trimmed = path.trim();
|
|
262
|
+
if (trimmed && !value.includes(trimmed)) {
|
|
263
|
+
onChange([...value, trimmed]);
|
|
264
|
+
}
|
|
265
|
+
}, [value, onChange]);
|
|
266
|
+
|
|
267
|
+
const remove = (path: string) => onChange(value.filter(v => v !== path));
|
|
268
|
+
|
|
269
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
setDragOver(false);
|
|
272
|
+
// FileTree drag format: text/mindos-path
|
|
273
|
+
const mindosPath = e.dataTransfer.getData('text/mindos-path');
|
|
274
|
+
if (mindosPath) {
|
|
275
|
+
addPath(mindosPath);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Fallback: plain text
|
|
279
|
+
const text = e.dataTransfer.getData('text/plain');
|
|
280
|
+
if (text) addPath(text);
|
|
281
|
+
}, [addPath]);
|
|
282
|
+
|
|
283
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
286
|
+
setDragOver(true);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
{/* Selected files */}
|
|
294
|
+
{value.length > 0 && (
|
|
295
|
+
<div className="flex flex-col gap-1 mb-1.5">
|
|
296
|
+
{value.map(p => (
|
|
297
|
+
<div key={p} className="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-2xs bg-muted/60 border border-border group">
|
|
298
|
+
<FileText size={11} className="text-muted-foreground shrink-0" />
|
|
299
|
+
<span className="text-foreground truncate flex-1" title={p}>{p}</span>
|
|
300
|
+
<button onClick={() => remove(p)} className="p-0.5 rounded hover:bg-[var(--error)]/10 text-muted-foreground hover:text-[var(--error)] opacity-0 group-hover:opacity-100 transition-all">
|
|
301
|
+
<X size={10} />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{/* Drop zone */}
|
|
309
|
+
<div
|
|
310
|
+
onDrop={handleDrop}
|
|
311
|
+
onDragOver={handleDragOver}
|
|
312
|
+
onDragLeave={handleDragLeave}
|
|
313
|
+
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border-2 border-dashed transition-colors ${
|
|
314
|
+
dragOver
|
|
315
|
+
? 'border-[var(--amber)] bg-[var(--amber)]/5'
|
|
316
|
+
: 'border-border hover:border-muted-foreground/30'
|
|
317
|
+
}`}
|
|
318
|
+
>
|
|
319
|
+
<FolderOpen size={14} className={`shrink-0 ${dragOver ? 'text-[var(--amber)]' : 'text-muted-foreground/40'}`} />
|
|
320
|
+
<span className="text-2xs text-muted-foreground">
|
|
321
|
+
Drag files here from the sidebar
|
|
322
|
+
</span>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
{/* Manual input */}
|
|
326
|
+
<div className="mt-1.5">
|
|
327
|
+
<input type="text" value={manualInput} onChange={e => setManualInput(e.target.value)}
|
|
328
|
+
onKeyDown={e => { if (e.key === 'Enter' && manualInput.trim()) { addPath(manualInput.trim()); setManualInput(''); } }}
|
|
329
|
+
placeholder="Or type a file path..."
|
|
330
|
+
className="w-full px-2.5 py-1 text-2xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Directory Picker ────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
async function fetchDirs(dirPath: string): Promise<string[]> {
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetch('/api/setup/ls', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({ path: dirPath || '~' }),
|
|
345
|
+
});
|
|
346
|
+
if (!res.ok) return [];
|
|
347
|
+
const data = await res.json();
|
|
348
|
+
return data.dirs ?? [];
|
|
349
|
+
} catch { return []; }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Get parent directory from a path (supports / and \) */
|
|
353
|
+
function getParentDir(p: string): string {
|
|
354
|
+
const trimmed = p.trim();
|
|
355
|
+
if (!trimmed) return '';
|
|
356
|
+
if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
|
|
357
|
+
const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
358
|
+
return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function DirPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
362
|
+
// ── Autocomplete state ──
|
|
363
|
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
364
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
365
|
+
const [activeIdx, setActiveIdx] = useState(-1);
|
|
366
|
+
const justSelectedRef = useRef(false);
|
|
367
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
368
|
+
|
|
369
|
+
// ── Browse dialog state ──
|
|
370
|
+
const [browseOpen, setBrowseOpen] = useState(false);
|
|
371
|
+
const [browsePath, setBrowsePath] = useState(value || '~');
|
|
372
|
+
const [browseDirs, setBrowseDirs] = useState<string[]>([]);
|
|
373
|
+
const [browseLoading, setBrowseLoading] = useState(false);
|
|
374
|
+
|
|
375
|
+
// Debounced autocomplete on typing
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (justSelectedRef.current) { justSelectedRef.current = false; return; }
|
|
378
|
+
if (!value.trim()) { setSuggestions([]); setShowSuggestions(false); return; }
|
|
379
|
+
const timer = setTimeout(async () => {
|
|
380
|
+
const parent = getParentDir(value) || '~';
|
|
381
|
+
const dirs = await fetchDirs(parent);
|
|
382
|
+
if (!dirs.length) { setSuggestions([]); setShowSuggestions(false); return; }
|
|
383
|
+
const sep = parent.includes('\\') ? '\\' : '/';
|
|
384
|
+
const parentNorm = (parent.endsWith('/') || parent.endsWith('\\')) ? parent : parent + sep;
|
|
385
|
+
const full = dirs.map(d => parentNorm + d);
|
|
386
|
+
const endsWithSep = value.endsWith('/') || value.endsWith('\\');
|
|
387
|
+
const filtered = endsWithSep ? full : full.filter(f => f.startsWith(value.trim()));
|
|
388
|
+
setSuggestions(filtered.slice(0, 15));
|
|
389
|
+
setShowSuggestions(filtered.length > 0);
|
|
390
|
+
setActiveIdx(-1);
|
|
391
|
+
}, 300);
|
|
392
|
+
return () => clearTimeout(timer);
|
|
393
|
+
}, [value]);
|
|
394
|
+
|
|
395
|
+
const hideSuggestions = () => { setSuggestions([]); setShowSuggestions(false); setActiveIdx(-1); };
|
|
396
|
+
|
|
397
|
+
const selectSuggestion = (val: string) => {
|
|
398
|
+
justSelectedRef.current = true;
|
|
399
|
+
onChange(val);
|
|
400
|
+
hideSuggestions();
|
|
401
|
+
inputRef.current?.focus();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
405
|
+
if (!showSuggestions || suggestions.length === 0) return;
|
|
406
|
+
if (e.key === 'ArrowDown') {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
|
|
409
|
+
} else if (e.key === 'ArrowUp') {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
setActiveIdx(i => Math.max(i - 1, -1));
|
|
412
|
+
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
selectSuggestion(suggestions[activeIdx]);
|
|
415
|
+
} else if (e.key === 'Escape') {
|
|
416
|
+
hideSuggestions();
|
|
417
|
+
} else if (e.key === 'Tab' && activeIdx >= 0) {
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
selectSuggestion(suggestions[activeIdx]);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Browse dialog
|
|
424
|
+
const loadBrowse = useCallback(async (dirPath: string) => {
|
|
425
|
+
setBrowseLoading(true);
|
|
426
|
+
const result = await fetchDirs(dirPath);
|
|
427
|
+
setBrowseDirs(result);
|
|
428
|
+
setBrowseLoading(false);
|
|
429
|
+
}, []);
|
|
430
|
+
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (browseOpen) loadBrowse(browsePath);
|
|
433
|
+
}, [browseOpen, browsePath, loadBrowse]);
|
|
434
|
+
|
|
435
|
+
const navigateInto = (subDir: string) => {
|
|
436
|
+
const next = browsePath === '~' ? `~/${subDir}` : `${browsePath.replace(/\/+$/, '')}/${subDir}`;
|
|
437
|
+
setBrowsePath(next);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const selectBrowsed = () => { onChange(browsePath); setBrowseOpen(false); };
|
|
441
|
+
|
|
442
|
+
const goUp = () => {
|
|
443
|
+
const parts = browsePath.split('/');
|
|
444
|
+
if (parts.length <= 1) return;
|
|
445
|
+
parts.pop();
|
|
446
|
+
setBrowsePath(parts.join('/') || '~');
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div className="relative flex gap-1.5">
|
|
451
|
+
{/* Input with autocomplete */}
|
|
452
|
+
<div className="relative flex-1">
|
|
453
|
+
<input
|
|
454
|
+
ref={inputRef}
|
|
455
|
+
type="text"
|
|
456
|
+
value={value}
|
|
457
|
+
onChange={e => { onChange(e.target.value); setShowSuggestions(true); }}
|
|
458
|
+
onKeyDown={handleKeyDown}
|
|
459
|
+
onBlur={() => setTimeout(hideSuggestions, 150)}
|
|
460
|
+
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
|
461
|
+
placeholder="~/projects/my-app"
|
|
462
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-lg border border-border bg-background text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
463
|
+
/>
|
|
464
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
465
|
+
<div role="listbox"
|
|
466
|
+
className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border border-border bg-card shadow-lg overflow-auto max-h-[200px]">
|
|
467
|
+
{suggestions.map((s, i) => (
|
|
468
|
+
<button key={s} type="button" role="option" aria-selected={i === activeIdx}
|
|
469
|
+
onMouseDown={() => selectSuggestion(s)}
|
|
470
|
+
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors flex items-center gap-1.5 ${
|
|
471
|
+
i === activeIdx ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted/50'
|
|
472
|
+
}`}>
|
|
473
|
+
<Folder size={11} className="text-[var(--amber)] shrink-0" />
|
|
474
|
+
<span className="truncate">{s}</span>
|
|
475
|
+
</button>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
{/* Browse button */}
|
|
482
|
+
<Dropdown
|
|
483
|
+
open={browseOpen}
|
|
484
|
+
onClose={() => setBrowseOpen(false)}
|
|
485
|
+
trigger={
|
|
486
|
+
<button type="button" onClick={() => { setBrowsePath(value || '~'); setBrowseOpen(v => !v); }}
|
|
487
|
+
className="px-2 py-1.5 rounded-lg border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
488
|
+
title="Browse directories"
|
|
489
|
+
>
|
|
490
|
+
<FolderOpen size={13} />
|
|
491
|
+
</button>
|
|
492
|
+
}
|
|
493
|
+
>
|
|
494
|
+
<div className="sticky top-0 bg-card border-b border-border px-2.5 py-2 flex items-center gap-1.5">
|
|
495
|
+
<span className="text-2xs font-mono text-muted-foreground truncate flex-1" title={browsePath}>{browsePath}</span>
|
|
496
|
+
<button onClick={selectBrowsed}
|
|
497
|
+
className="px-2 py-0.5 text-2xs rounded font-medium bg-[var(--amber)] text-[var(--amber-foreground)] shrink-0">
|
|
498
|
+
Select
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
{browsePath !== '~' && (
|
|
502
|
+
<button onClick={goUp}
|
|
503
|
+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5">
|
|
504
|
+
<Folder size={12} className="shrink-0" /> ..
|
|
505
|
+
</button>
|
|
506
|
+
)}
|
|
507
|
+
{browseLoading && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
|
|
508
|
+
{!browseLoading && browseDirs.length === 0 && (
|
|
509
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No subdirectories</div>
|
|
510
|
+
)}
|
|
511
|
+
{browseDirs.map(d => (
|
|
512
|
+
<button key={d} onClick={() => navigateInto(d)}
|
|
513
|
+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-foreground flex items-center gap-1.5">
|
|
514
|
+
<Folder size={12} className="text-[var(--amber)] shrink-0" />
|
|
515
|
+
<span className="truncate flex-1">{d}</span>
|
|
516
|
+
<ChevronRight size={10} className="text-muted-foreground/40 shrink-0" />
|
|
517
|
+
</button>
|
|
518
|
+
))}
|
|
519
|
+
</Dropdown>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|