@agentprojectcontext/apx 1.59.0 → 1.60.1

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_asana.js +34 -0
  3. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  4. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  5. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  6. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +11 -0
  9. package/src/core/integrations/catalog.js +66 -0
  10. package/src/core/integrations/index.js +10 -0
  11. package/src/core/integrations/plugins/asana.js +231 -0
  12. package/src/core/integrations/sources.js +56 -0
  13. package/src/core/integrations/store.js +118 -0
  14. package/src/core/stores/project-files.js +21 -2
  15. package/src/host/daemon/api/integrations.js +191 -0
  16. package/src/host/daemon/api.js +2 -0
  17. package/src/interfaces/web/dist/assets/{index-CnQb4N6C.js → index-DFNV6BWh.js} +193 -163
  18. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +1 -0
  19. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  22. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  23. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  24. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  25. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  26. package/src/interfaces/web/src/lib/api.ts +1 -0
  27. package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
  28. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  29. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +0 -1
  30. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +0 -1
@@ -0,0 +1,275 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import {
4
+ AlertCircle, CheckCircle2, ChevronDown, ExternalLink, Eye, EyeOff, Loader2, WifiOff, X,
5
+ } from "lucide-react";
6
+ import { cn } from "../../lib/cn";
7
+ import { Integrations, type IntegrationScope, type IntegrationStatus } from "../../lib/api";
8
+ import { PluginCard } from "./PluginCard";
9
+ import { PluginToolsSection, type PluginTool } from "./PluginToolsSection";
10
+
11
+ function AsanaLogo({ className }: { className?: string }) {
12
+ return (
13
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor">
14
+ <path d="M18.833 9.637a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zm-13.666 0a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zM12 2a4.167 4.167 0 1 1 0 8.333A4.167 4.167 0 0 1 12 2z" />
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ const ASANA_TOOLS: PluginTool[] = [
20
+ { slug: "asana_list_projects", desc: "Listar proyectos del workspace" },
21
+ { slug: "asana_list_tasks", desc: "Listar tareas de un proyecto" },
22
+ { slug: "asana_create_task", desc: "Crear una tarea" },
23
+ { slug: "asana_update_task", desc: "Actualizar estado o campos de una tarea" },
24
+ ];
25
+
26
+ const HELP_STEPS = [
27
+ "Abrí app.asana.com/0/my-apps en el navegador.",
28
+ 'Bajá hasta la sección "Personal access tokens" (no tus apps OAuth).',
29
+ 'Hacé clic en "+ New access token".',
30
+ "Dale un nombre y confirmá.",
31
+ 'Copiá el token completo — empieza con "1/..." y tiene un ":" en el medio.',
32
+ "Pegalo en el campo de abajo.",
33
+ ];
34
+
35
+ type Step = "idle" | "saving" | "validating" | "done";
36
+
37
+ export function AsanaPlugin({ pid, scope }: { pid: string; scope: IntegrationScope }) {
38
+ const [expanded, setExpanded] = useState(false);
39
+ const [pat, setPat] = useState("");
40
+ const [showPat, setShowPat] = useState(false);
41
+ const [showHelp, setShowHelp] = useState(false);
42
+ const [step, setStep] = useState<Step>("idle");
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [workspaces, setWorkspaces] = useState<{ gid: string; name: string }[]>([]);
45
+ const [selectedWorkspace, setSelectedWorkspace] = useState("");
46
+
47
+ const key = `asana-status-${pid}-${scope}`;
48
+ const { data: status, mutate, isLoading } = useSWR<IntegrationStatus>(
49
+ key,
50
+ () => Integrations.status(pid, "asana", scope),
51
+ { shouldRetryOnError: false },
52
+ );
53
+
54
+ const isActive = status?.status === "active" && status.is_enabled;
55
+ const busy = step === "saving" || step === "validating";
56
+
57
+ async function handleConnect() {
58
+ if (!pat.trim()) return;
59
+ setStep("saving");
60
+ setError(null);
61
+ try {
62
+ await Integrations.asanaConfigure(pid, scope, { personalAccessToken: pat.trim() });
63
+ setStep("validating");
64
+ const result = await Integrations.asanaValidate(pid, scope);
65
+ await mutate();
66
+ if (!result.workspace_gid) {
67
+ const ws = await Integrations.asanaWorkspaces(pid, scope);
68
+ if (ws.workspaces.length > 1) setWorkspaces(ws.workspaces);
69
+ }
70
+ setStep("done");
71
+ setPat("");
72
+ } catch (err) {
73
+ setError(err instanceof Error ? err.message : "Error al conectar con Asana");
74
+ setStep("idle");
75
+ }
76
+ }
77
+
78
+ async function handleSelectWorkspace() {
79
+ if (!selectedWorkspace) return;
80
+ setStep("saving");
81
+ setError(null);
82
+ try {
83
+ await Integrations.asanaConfigure(pid, scope, { workspaceGid: selectedWorkspace });
84
+ await Integrations.asanaValidate(pid, scope);
85
+ await mutate();
86
+ setWorkspaces([]);
87
+ setStep("done");
88
+ } catch (err) {
89
+ setError(err instanceof Error ? err.message : "Error al seleccionar workspace");
90
+ setStep("idle");
91
+ }
92
+ }
93
+
94
+ async function handleDeactivate() {
95
+ setError(null);
96
+ try {
97
+ await Integrations.deactivate(pid, "asana", scope);
98
+ await mutate();
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : "Error al desactivar");
101
+ }
102
+ }
103
+
104
+ return (
105
+ <PluginCard
106
+ icon={
107
+ <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl border border-rose-500/30 bg-gradient-to-br from-rose-500/20 to-pink-500/20">
108
+ <AsanaLogo className="h-6 w-6 text-rose-400" />
109
+ </div>
110
+ }
111
+ title="Asana"
112
+ description="Conectá tu workspace de Asana para que los agentes creen, actualicen y consulten tareas"
113
+ hasTools
114
+ badges={
115
+ <span
116
+ className={cn(
117
+ "flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px]",
118
+ isActive
119
+ ? "border-emerald-700 bg-emerald-900/20 text-emerald-400"
120
+ : "border-border bg-muted text-muted-foreground",
121
+ )}
122
+ >
123
+ <span className={cn("h-1.5 w-1.5 rounded-full", isActive ? "bg-emerald-400" : "bg-muted-foreground")} />
124
+ {isLoading ? "..." : isActive ? "Activo" : status?.status === "error" ? "Error" : "No configurado"}
125
+ </span>
126
+ }
127
+ rightContent={
128
+ isActive && status?.workspace_name ? (
129
+ <span className="max-w-[120px] truncate font-mono text-[10px] text-muted-foreground">
130
+ {status.workspace_name}
131
+ </span>
132
+ ) : null
133
+ }
134
+ expanded={expanded}
135
+ onToggle={() => setExpanded((v) => !v)}
136
+ >
137
+ <div className="space-y-4 p-4">
138
+ {error && (
139
+ <div className="flex items-center gap-2 rounded-lg border border-red-700/30 bg-red-900/20 px-3 py-2.5 text-xs text-red-300">
140
+ <AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
141
+ <span className="flex-1">{error}</span>
142
+ <button onClick={() => setError(null)}><X className="h-3.5 w-3.5" /></button>
143
+ </div>
144
+ )}
145
+
146
+ {isActive && (
147
+ <div className="space-y-1 rounded-xl border border-emerald-700/30 bg-emerald-900/10 p-3">
148
+ <div className="flex items-center gap-2">
149
+ <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
150
+ <span className="text-xs font-medium text-emerald-300">Conectado como {status?.user_name}</span>
151
+ </div>
152
+ {status?.user_email && <p className="pl-5 text-[10px] text-muted-foreground">{status.user_email}</p>}
153
+ {status?.workspace_name && (
154
+ <p className="pl-5 text-[10px] text-muted-foreground">Workspace: {status.workspace_name}</p>
155
+ )}
156
+ </div>
157
+ )}
158
+
159
+ {workspaces.length > 1 && (
160
+ <div className="space-y-2">
161
+ <p className="text-[11px] text-muted-foreground">Seleccioná el workspace a usar:</p>
162
+ <div className="flex gap-2">
163
+ <select
164
+ value={selectedWorkspace}
165
+ onChange={(e) => setSelectedWorkspace(e.target.value)}
166
+ className="flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs outline-none focus:border-rose-500/50"
167
+ >
168
+ <option value="">Seleccionar workspace...</option>
169
+ {workspaces.map((ws) => (
170
+ <option key={ws.gid} value={ws.gid}>{ws.name}</option>
171
+ ))}
172
+ </select>
173
+ <button
174
+ onClick={handleSelectWorkspace}
175
+ disabled={!selectedWorkspace || busy}
176
+ className="rounded-lg border border-rose-700/50 px-3 py-1.5 text-xs text-rose-400 transition-all hover:bg-rose-900/20 disabled:cursor-not-allowed disabled:opacity-50"
177
+ >
178
+ {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Confirmar"}
179
+ </button>
180
+ </div>
181
+ </div>
182
+ )}
183
+
184
+ {(!isActive || step === "idle") && workspaces.length === 0 && (
185
+ <div className="space-y-3">
186
+ <p className="mb-1 text-xs font-semibold text-foreground">Credenciales Asana</p>
187
+
188
+ <div className="overflow-hidden rounded-lg border border-border">
189
+ <button
190
+ type="button"
191
+ onClick={() => setShowHelp((v) => !v)}
192
+ className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/40"
193
+ >
194
+ <span className="text-[11px] text-muted-foreground">
195
+ ¿Cómo obtener el token? ·{" "}
196
+ <a
197
+ href="https://app.asana.com/0/my-apps"
198
+ target="_blank"
199
+ rel="noreferrer"
200
+ onClick={(e) => e.stopPropagation()}
201
+ className="inline-flex items-center gap-0.5 text-rose-400 hover:underline"
202
+ >
203
+ app.asana.com/0/my-apps <ExternalLink className="h-2.5 w-2.5" />
204
+ </a>
205
+ </span>
206
+ <ChevronDown className={cn("h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform", showHelp && "rotate-180")} />
207
+ </button>
208
+ {showHelp && (
209
+ <div className="space-y-1.5 border-t border-border px-3 pb-3 pt-2.5">
210
+ {HELP_STEPS.map((s, i) => (
211
+ <div key={i} className="flex items-start gap-2">
212
+ <span className="mt-0.5 flex-shrink-0 font-mono text-[10px] text-rose-400/70">{i + 1}.</span>
213
+ <p className="text-[11px] text-muted-foreground">{s}</p>
214
+ </div>
215
+ ))}
216
+ </div>
217
+ )}
218
+ </div>
219
+
220
+ <div>
221
+ <label className="mb-1 block text-[10px] text-muted-foreground">Personal Access Token</label>
222
+ <div className="relative">
223
+ <input
224
+ type={showPat ? "text" : "password"}
225
+ placeholder="1/1234567890abcdef:..."
226
+ value={pat}
227
+ onChange={(e) => setPat(e.target.value)}
228
+ onKeyDown={(e) => e.key === "Enter" && handleConnect()}
229
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 pr-14 font-mono text-xs outline-none placeholder:text-muted-foreground/60 focus:border-rose-500/50"
230
+ />
231
+ <button
232
+ type="button"
233
+ onClick={() => setShowPat((v) => !v)}
234
+ className="absolute right-2.5 top-1/2 flex -translate-y-1/2 items-center gap-0.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
235
+ >
236
+ {showPat ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
237
+ {showPat ? "Ocultar" : "Ver"}
238
+ </button>
239
+ </div>
240
+ </div>
241
+
242
+ {step === "validating" && (
243
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
244
+ <Loader2 className="h-3.5 w-3.5 animate-spin" /> Verificando token con Asana...
245
+ </div>
246
+ )}
247
+
248
+ <button
249
+ onClick={handleConnect}
250
+ disabled={!pat.trim() || busy}
251
+ className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-rose-700/50 px-3 py-2 text-xs text-rose-400 transition-all hover:bg-rose-900/20 disabled:cursor-not-allowed disabled:opacity-50"
252
+ >
253
+ {busy ? (
254
+ <><Loader2 className="h-3.5 w-3.5 animate-spin" />{step === "saving" ? "Guardando..." : "Validando..."}</>
255
+ ) : isActive ? "Reconectar" : "Conectar"}
256
+ </button>
257
+ </div>
258
+ )}
259
+
260
+ <PluginToolsSection pid={pid} tools={ASANA_TOOLS} isActive={!!isActive} />
261
+
262
+ {isActive && (
263
+ <div className="flex justify-end border-t border-border pt-2">
264
+ <button
265
+ onClick={handleDeactivate}
266
+ className="flex items-center gap-1.5 rounded-lg border border-red-700/50 px-3 py-1.5 text-xs text-red-400 transition-all hover:bg-red-900/20"
267
+ >
268
+ <WifiOff className="h-3.5 w-3.5" /> Desactivar
269
+ </button>
270
+ </div>
271
+ )}
272
+ </div>
273
+ </PluginCard>
274
+ );
275
+ }
@@ -0,0 +1,44 @@
1
+ import { useState } from "react";
2
+ import { Github, MessageCircle, Mic, Puzzle } from "lucide-react";
3
+ import type { CatalogEntry } from "../../lib/api";
4
+ import { PluginCard } from "./PluginCard";
5
+
6
+ const ICONS: Record<string, { icon: typeof Puzzle; className: string; wrap: string }> = {
7
+ github: { icon: Github, className: "text-slate-200", wrap: "border-slate-500/30 from-slate-500/20 to-slate-700/20" },
8
+ whatsapp: { icon: MessageCircle, className: "text-[#25D366]", wrap: "border-[#25D366]/30 from-[#25D366]/20 to-[#128C7E]/20" },
9
+ "local-transcription": { icon: Mic, className: "text-orange-400", wrap: "border-orange-500/30 from-orange-500/20 to-amber-500/20" },
10
+ };
11
+
12
+ // Placeholder card for catalog plugins that are declared but not yet wired to a
13
+ // live service module (github/whatsapp/transcription). Keeps the Integrations
14
+ // page complete and makes it obvious what's on the roster.
15
+ export function ComingSoonPlugin({ entry }: { entry: CatalogEntry }) {
16
+ const [expanded, setExpanded] = useState(false);
17
+ const cfg = ICONS[entry.slug] || { icon: Puzzle, className: "text-muted-foreground", wrap: "border-border from-muted to-muted" };
18
+ const Icon = cfg.icon;
19
+
20
+ return (
21
+ <PluginCard
22
+ icon={
23
+ <div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl border bg-gradient-to-br ${cfg.wrap}`}>
24
+ <Icon className={`h-6 w-6 ${cfg.className}`} />
25
+ </div>
26
+ }
27
+ title={entry.name}
28
+ description={entry.description}
29
+ badges={
30
+ <span className="rounded-full border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
31
+ Próximamente
32
+ </span>
33
+ }
34
+ expanded={expanded}
35
+ onToggle={() => setExpanded((v) => !v)}
36
+ >
37
+ <div className="p-4 text-xs text-muted-foreground">
38
+ Este plugin está declarado en el catálogo pero todavía no está conectable en APX. La
39
+ infraestructura de <span className="font-mono">{entry.slug}</span> se va a portar de forma
40
+ nativa en una próxima iteración.
41
+ </div>
42
+ </PluginCard>
43
+ );
44
+ }
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from "react";
2
+ import { ChevronRight, Wrench } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Tip } from "../ui";
5
+
6
+ // Collapsible card shell for one integration plugin. Ported from PandaProject's
7
+ // PluginCard but restyled onto APX's design tokens (border/card/muted).
8
+ export function PluginCard({
9
+ icon,
10
+ title,
11
+ description,
12
+ badges,
13
+ rightContent,
14
+ hasTools,
15
+ expanded,
16
+ onToggle,
17
+ children,
18
+ }: {
19
+ icon: ReactNode;
20
+ title: string;
21
+ description: string;
22
+ badges?: ReactNode;
23
+ rightContent?: ReactNode;
24
+ hasTools?: boolean;
25
+ expanded: boolean;
26
+ onToggle: () => void;
27
+ children?: ReactNode;
28
+ }) {
29
+ return (
30
+ <div className="overflow-hidden rounded-xl border border-border bg-card">
31
+ <button
32
+ type="button"
33
+ className="flex w-full items-center gap-4 p-4 text-left transition-colors hover:bg-muted/40"
34
+ onClick={onToggle}
35
+ >
36
+ {icon}
37
+ <div className="min-w-0 flex-1">
38
+ <div className="flex flex-wrap items-center gap-2">
39
+ <p className="text-sm font-semibold text-foreground">{title}</p>
40
+ {badges}
41
+ {hasTools && (
42
+ <Tip content="Esta integración expone tools para los agentes">
43
+ <span>
44
+ <Wrench className="h-3 w-3 text-muted-foreground" />
45
+ </span>
46
+ </Tip>
47
+ )}
48
+ </div>
49
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">{description}</p>
50
+ </div>
51
+ <div className="flex flex-shrink-0 items-center gap-2">
52
+ {rightContent}
53
+ <ChevronRight
54
+ className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-90")}
55
+ />
56
+ </div>
57
+ </button>
58
+ {expanded && children && <div className="border-t border-border">{children}</div>}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,39 @@
1
+ import { Wrench } from "lucide-react";
2
+
3
+ export interface PluginTool {
4
+ slug: string;
5
+ desc: string;
6
+ }
7
+
8
+ // Lists the agent tools a connected plugin exposes. Read-only: the tools are
9
+ // registered in APX's tool registry (category "integrations") and become
10
+ // callable for any agent whose role gate allows them, or via discover_tools().
11
+ export function PluginToolsSection({ tools, isActive }: { pid: string; tools: PluginTool[]; isActive: boolean }) {
12
+ if (!isActive) return null;
13
+
14
+ return (
15
+ <div className="space-y-2.5 rounded-xl border border-border bg-muted/30 p-3">
16
+ <div className="flex items-center justify-between">
17
+ <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
18
+ Tools para agentes
19
+ </p>
20
+ </div>
21
+ <div className="flex flex-wrap gap-1.5">
22
+ {tools.map((t) => (
23
+ <div
24
+ key={t.slug}
25
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-2 py-1"
26
+ >
27
+ <Wrench className="h-2.5 w-2.5 flex-shrink-0 text-muted-foreground" />
28
+ <span className="font-mono text-[10px] text-foreground">{t.slug}</span>
29
+ <span className="text-[10px] text-muted-foreground/60">·</span>
30
+ <span className="text-[10px] text-muted-foreground">{t.desc}</span>
31
+ </div>
32
+ ))}
33
+ </div>
34
+ <p className="text-[10px] text-muted-foreground/70">
35
+ Disponibles para los agentes que las tengan permitidas, o vía <span className="font-mono">discover_tools</span>.
36
+ </p>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,106 @@
1
+ import { http } from "../http";
2
+
3
+ // Where an integration record is stored. "global" targets the default project's
4
+ // store (shared across projects); "project" targets the current project. A
5
+ // project uses its own record when present, otherwise the global one.
6
+ export type IntegrationScope = "project" | "global";
7
+
8
+ // Status returned by a plugin's status endpoint. Common fields plus
9
+ // plugin-specific extras (Asana adds user/workspace metadata).
10
+ export interface IntegrationStatus {
11
+ slug: string;
12
+ status: string;
13
+ is_enabled: boolean;
14
+ user_name?: string | null;
15
+ user_email?: string | null;
16
+ workspace_gid?: string | null;
17
+ workspace_name?: string | null;
18
+ }
19
+
20
+ export interface PluginTool {
21
+ slug: string;
22
+ desc: string;
23
+ }
24
+
25
+ // One entry of the plugin catalog with its resolved status for this project.
26
+ export interface CatalogEntry {
27
+ slug: string;
28
+ name: string;
29
+ type: string;
30
+ description: string;
31
+ auth: string;
32
+ tools?: PluginTool[];
33
+ coming_soon: boolean;
34
+ status: IntegrationStatus;
35
+ resolved_scope: IntegrationScope | null;
36
+ }
37
+
38
+ // A stored integration record (secrets redacted; `<key>_set` booleans instead).
39
+ export interface IntegrationRecord {
40
+ slug: string;
41
+ name: string;
42
+ type: string;
43
+ description: string;
44
+ source: string;
45
+ status: string;
46
+ is_enabled: boolean;
47
+ config: Record<string, unknown>;
48
+ created_at: string;
49
+ updated_at: string;
50
+ }
51
+
52
+ export interface AsanaConfigureBody {
53
+ personalAccessToken?: string;
54
+ workspaceGid?: string;
55
+ }
56
+
57
+ export interface AsanaValidateResult {
58
+ ok: boolean;
59
+ user_name?: string | null;
60
+ user_email?: string | null;
61
+ workspace_gid?: string | null;
62
+ workspace_name?: string | null;
63
+ error?: string;
64
+ }
65
+
66
+ export interface AsanaWorkspaces {
67
+ workspaces: { gid: string; name: string }[];
68
+ }
69
+
70
+ const q = (scope: IntegrationScope) => `?scope=${scope}`;
71
+
72
+ export const Integrations = {
73
+ catalog: (pid: string) =>
74
+ http.get<CatalogEntry[]>(`/projects/${pid}/integrations/catalog`),
75
+
76
+ list: (pid: string, scope: IntegrationScope = "project") =>
77
+ http.get<IntegrationRecord[]>(`/projects/${pid}/integrations${q(scope)}`),
78
+
79
+ status: (pid: string, slug: string, scope: IntegrationScope = "project") =>
80
+ http.get<IntegrationStatus>(`/projects/${pid}/integrations/${slug}${q(scope)}`),
81
+
82
+ configure: (pid: string, slug: string, scope: IntegrationScope, body: Record<string, unknown>) =>
83
+ http.post<IntegrationRecord>(`/projects/${pid}/integrations/${slug}/configure${q(scope)}`, body),
84
+
85
+ validate: (pid: string, slug: string, scope: IntegrationScope = "project") =>
86
+ http.post<AsanaValidateResult>(`/projects/${pid}/integrations/${slug}/validate${q(scope)}`, {}),
87
+
88
+ deactivate: (pid: string, slug: string, scope: IntegrationScope = "project") =>
89
+ http.post<IntegrationStatus>(`/projects/${pid}/integrations/${slug}/deactivate${q(scope)}`, {}),
90
+
91
+ action: <T>(pid: string, slug: string, action: string, scope: IntegrationScope = "project") =>
92
+ http.post<T>(`/projects/${pid}/integrations/${slug}/action/${action}${q(scope)}`, {}),
93
+
94
+ remove: (pid: string, slug: string, scope: IntegrationScope = "project") =>
95
+ http.del<void>(`/projects/${pid}/integrations/${slug}${q(scope)}`),
96
+
97
+ // ── Asana convenience wrappers ──────────────────────────────────────────────
98
+ asanaConfigure: (pid: string, scope: IntegrationScope, body: AsanaConfigureBody) =>
99
+ Integrations.configure(pid, "asana", scope, {
100
+ personal_access_token: body.personalAccessToken,
101
+ workspace_gid: body.workspaceGid,
102
+ }),
103
+ asanaValidate: (pid: string, scope: IntegrationScope) => Integrations.validate(pid, "asana", scope),
104
+ asanaWorkspaces: (pid: string, scope: IntegrationScope) =>
105
+ Integrations.action<AsanaWorkspaces>(pid, "asana", "workspaces", scope),
106
+ };
@@ -9,6 +9,7 @@ export * from "./api/conversations";
9
9
  export * from "./api/routines";
10
10
  export * from "./api/tasks";
11
11
  export * from "./api/mcps";
12
+ export * from "./api/integrations";
12
13
  export * from "./api/vars";
13
14
  export * from "./api/messages";
14
15
  export * from "./api/sessions";
@@ -3,7 +3,7 @@ import { useParams, Routes, Route, Navigate, useLocation, useNavigate } from "re
3
3
  import {
4
4
  Bot, Heart, Zap, Puzzle, FolderKanban, Settings,
5
5
  MessagesSquare, Send, KeyRound,
6
- LayoutDashboard, Boxes, Cpu, ScrollText, History, Brain, FileCode2,
6
+ LayoutDashboard, Boxes, Cpu, ScrollText, History, Brain, FileCode2, Cable,
7
7
  Building2, FileText, FolderTree, Sparkles,
8
8
  } from "lucide-react";
9
9
  import { useNavCollapse, type TabSection } from "../components/common/TabNav";
@@ -25,6 +25,7 @@ import { AgentsTab } from "./project/AgentsTab";
25
25
  import { RoutinesTab } from "./project/RoutinesTab";
26
26
  import { TasksTab } from "./project/TasksTab";
27
27
  import { McpsTab } from "./project/McpsTab";
28
+ import { IntegrationsTab } from "./project/IntegrationsTab";
28
29
  import { VarsTab } from "./project/VarsTab";
29
30
  import { ChatTab } from "./project/ChatTab";
30
31
  import { TelegramTab } from "./project/TelegramTab";
@@ -38,7 +39,7 @@ import { SkillsTab } from "./project/SkillsTab";
38
39
 
39
40
  type NavKey =
40
41
  | "" | "chat" | "config" | "telegram"
41
- | "agents" | "routines" | "tasks" | "mcps" | "vars" | "logs" | "memories" | "artifacts"
42
+ | "agents" | "routines" | "tasks" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
42
43
  | "structure" | "docs" | "files" | "skills";
43
44
 
44
45
  export function ProjectScreen() {
@@ -79,6 +80,7 @@ export function ProjectScreen() {
79
80
  { key: "skills", label: t("skills_page.title"), icon: Sparkles },
80
81
  { key: "routines", label: t("project.nav.routines"), icon: Heart },
81
82
  { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
83
+ { key: "integrations", label: "Integrations", icon: Cable },
82
84
  { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
83
85
  { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
84
86
  { key: "config", label: t("project.nav.config"), icon: Settings },
@@ -115,6 +117,7 @@ export function ProjectScreen() {
115
117
  { key: "routines", label: t("project.nav.routines"), icon: Heart },
116
118
  { key: "tasks", label: t("project.nav.tasks"), icon: Zap },
117
119
  { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
120
+ { key: "integrations", label: "Integrations", icon: Cable },
118
121
  { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
119
122
  { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
120
123
  { key: "logs", label: t("project.nav.logs"), icon: ScrollText },
@@ -182,6 +185,7 @@ export function ProjectScreen() {
182
185
  <Route path="routines" element={<RoutinesTab pid={pid} />} />
183
186
  <Route path="tasks" element={isBase ? <GlobalTasksTab /> : <TasksTab pid={pid} />} />
184
187
  <Route path="mcps" element={<McpsTab pid={pid} />} />
188
+ <Route path="integrations" element={<IntegrationsTab pid={pid} />} />
185
189
  <Route path="artifacts" element={<ArtifactsTab pid={pid} />} />
186
190
  <Route path="vars" element={<VarsTab pid={pid} />} />
187
191
  <Route path="threads" element={<Navigate to={`/p/${pid}/chat`} replace />} />